From ee1fe02bbac46066c13a02e98e35189e0153ec5f Mon Sep 17 00:00:00 2001 From: Peter Cai <222655+pcai@users.noreply.github.com> Date: Wed, 22 Mar 2023 18:14:30 +0000 Subject: [PATCH] initial --- .devcontainer/devcontainer.json | 22 ++++++++++++ .gitignore | 1 + CHANGELOG.md | 7 ++++ Gemfile | 3 ++ Gemfile.lock | 52 ++++++++++++++++++++++++++++ LICENSE | 21 +++++++++++ lib/safe_query.rb | 47 +++++++++++++++++++++++++ safe_query.gemspec | 29 ++++++++++++++++ spec/activerecord/safe_query_spec.rb | 19 ++++++++++ spec/database.yml | 2 ++ spec/models/user.rb | 3 ++ spec/spec_helper.rb | 11 ++++++ 12 files changed, 217 insertions(+) create mode 100644 .devcontainer/devcontainer.json create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 Gemfile create mode 100644 Gemfile.lock create mode 100644 LICENSE create mode 100644 lib/safe_query.rb create mode 100644 safe_query.gemspec create mode 100644 spec/activerecord/safe_query_spec.rb create mode 100644 spec/database.yml create mode 100644 spec/models/user.rb create mode 100644 spec/spec_helper.rb diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..0966804 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,22 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/ruby +{ + "name": "Ruby", + // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile + "image": "mcr.microsoft.com/devcontainers/ruby:0-3.1-bullseye" + + // Features to add to the dev container. More info: https://containers.dev/features. + // "features": {}, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + // Use 'postCreateCommand' to run commands after the container is created. + // "postCreateCommand": "ruby --version", + + // Configure tool-specific properties. + // "customizations": {}, + + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..465d029 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +spec/test.db diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6a92e8d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,7 @@ +# SafeQuery changelog + +## Unreleased +- Add your PR changelog line here + +## 0.1.0 (2023-03-22) +- Initial release diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..fa75df1 --- /dev/null +++ b/Gemfile @@ -0,0 +1,3 @@ +source 'https://rubygems.org' + +gemspec diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..7bd3cf9 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,52 @@ +PATH + remote: . + specs: + safe_query (0.1.0) + activerecord (>= 5.0) + activesupport (>= 5.0) + +GEM + remote: https://rubygems.org/ + specs: + activemodel (7.0.4.3) + activesupport (= 7.0.4.3) + activerecord (7.0.4.3) + activemodel (= 7.0.4.3) + activesupport (= 7.0.4.3) + activesupport (7.0.4.3) + concurrent-ruby (~> 1.0, >= 1.0.2) + i18n (>= 1.6, < 2) + minitest (>= 5.1) + tzinfo (~> 2.0) + concurrent-ruby (1.2.2) + diff-lcs (1.5.0) + i18n (1.12.0) + concurrent-ruby (~> 1.0) + minitest (5.18.0) + rspec (3.12.0) + rspec-core (~> 3.12.0) + rspec-expectations (~> 3.12.0) + rspec-mocks (~> 3.12.0) + rspec-core (3.12.1) + rspec-support (~> 3.12.0) + rspec-expectations (3.12.2) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.12.0) + rspec-mocks (3.12.4) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.12.0) + rspec-support (3.12.0) + sqlite3 (1.6.1-x86_64-linux) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + +PLATFORMS + x86_64-linux + +DEPENDENCIES + rspec (>= 3.12) + safe_query! + sqlite3 (>= 1.6.1) + +BUNDLED WITH + 2.3.26 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1c084e0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Peter Cai + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/lib/safe_query.rb b/lib/safe_query.rb new file mode 100644 index 0000000..736cbec --- /dev/null +++ b/lib/safe_query.rb @@ -0,0 +1,47 @@ +module ActiveRecord + class UnsafeQueryError < StandardError + # skip ourselves in the backtrace so it ends in the user code that generated the issue + def backtrace + return @lines if @lines + @lines = super + @lines.shift if @lines.present? + @lines + end + end + + class Relation + module SafeQuery + def each + QueryRegistry.reset + super + + query_to_check = QueryRegistry.queries.first.to_s + + unless query_to_check.blank? || query_to_check.upcase.include?("LIMIT ") || query_to_check.upcase.include?("IN ") + raise UnsafeQueryError, "Detected a potentially dangerous #each iterator on an unpaginated query. " + + "Perhaps you need to add pagination, a limit clause, or use the ActiveRecord::Batches methods. \n\n" + + "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" ++ + "Potentially unpaginated query: \n\n #{query_to_check}" + end + end + + ActiveSupport::Notifications.subscribe("sql.active_record") do |*, payload| + QueryRegistry.queries << payload[:sql] + end + + module QueryRegistry + extend self + + def queries + ActiveSupport::IsolatedExecutionState[:active_record_query_registry] ||= [] + end + + def reset + queries.clear + end + end + end + end +end + +ActiveRecord::Relation.prepend ActiveRecord::Relation::SafeQuery diff --git a/safe_query.gemspec b/safe_query.gemspec new file mode 100644 index 0000000..4c62033 --- /dev/null +++ b/safe_query.gemspec @@ -0,0 +1,29 @@ +# -*- encoding : utf-8 -*- +lib = File.expand_path("../lib", __FILE__) +$:.unshift lib unless $:.include? lib + +Gem::Specification.new do |s| + s.name = "safe_query" + s.version = "0.1.0" + s.authors = "Peter Cai" + s.email = "hello@petercai.com" + s.homepage = "https://github.com/pcai/safe_query" + s.summary = "Safely query stuff in ActiveRecord" + s.description = <<-EOF + Helps developers avoid unsafe queries in ActiveRecord. This gem will raise an error + when iterating over a relation that is potentially unpaginated. + EOF + s.required_ruby_version = '>= 2.6.0' + + s.license = 'MIT' + + s.add_dependency "activerecord", ">= 5.0", "< 8.0" + s.add_dependency "activesupport", ">= 5.0", "< 8.0" + + s.add_development_dependency "rspec", "~> 3.12" + s.add_development_dependency "sqlite3", "~> 1.6.1" + + s.files = Dir['CHANGELOG.md', 'LICENSE', 'README.md', 'lib/**/*.rb'] + + s.require_path = "lib" +end diff --git a/spec/activerecord/safe_query_spec.rb b/spec/activerecord/safe_query_spec.rb new file mode 100644 index 0000000..a582462 --- /dev/null +++ b/spec/activerecord/safe_query_spec.rb @@ -0,0 +1,19 @@ +require "spec_helper" + +RSpec.describe ActiveRecord::Relation::SafeQuery do + it "raises an error when iterating over a relation without a limit" do + expect { User.all.each {} }.to raise_error(ActiveRecord::UnsafeQueryError) + end + + it "does not raise an error when iterating over a relation with a limit" do + expect { User.limit(1).each {} }.to_not raise_error + end + + it "does not raise an error when iterating over a relation with an in clause" do + expect { User.where(id: [1, 2, 3]).each {} }.to_not raise_error + end + + it "does not raise an error when iterating over a relation with an in clause and a limit" do + expect { User.where(id: [1, 2, 3]).limit(1).each {} }.to_not raise_error + end +end diff --git a/spec/database.yml b/spec/database.yml new file mode 100644 index 0000000..5914c49 --- /dev/null +++ b/spec/database.yml @@ -0,0 +1,2 @@ +adapter: 'sqlite3' +database: 'spec/test.db' diff --git a/spec/models/user.rb b/spec/models/user.rb new file mode 100644 index 0000000..2a45220 --- /dev/null +++ b/spec/models/user.rb @@ -0,0 +1,3 @@ +class User < ActiveRecord::Base + +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..ed5cf4c --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,11 @@ +require "bundler" +Bundler.setup(:default, :development) + +require "active_support/all" +require "active_record" +require "safe_query" + +ActiveRecord::Base.establish_connection YAML::load(File.open('spec/database.yml')) +ActiveRecord::Base.connection.execute "CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, email TEXT, created_at DATETIME, updated_at DATETIME)" + +require "models/user"