├── .circleci └── config.yml ├── .github ├── dependabot.yml └── workflows │ └── test.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── .ruby-version ├── .travis.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Gemfile ├── LICENSE.md ├── README.md ├── Rakefile ├── bin ├── console ├── rake ├── rspec ├── rubocop └── setup ├── graph_attack.gemspec ├── lib ├── graph_attack.rb └── graph_attack │ ├── configuration.rb │ ├── error.rb │ ├── rate_limit.rb │ ├── rate_limited.rb │ └── version.rb └── spec ├── graph_attack ├── configuration_spec.rb └── rate_limit_spec.rb ├── graph_attack_spec.rb └── spec_helper.rb /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Ruby CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-ruby/ for more details 4 | # 5 | version: 2 6 | jobs: 7 | build: 8 | docker: 9 | - image: circleci/ruby:2.7.3 10 | - image: redis 11 | 12 | working_directory: ~/repo 13 | 14 | steps: 15 | - checkout 16 | 17 | # Download and cache dependencies 18 | - restore_cache: 19 | keys: 20 | - v2-dependencies-{{ checksum "graph_attack.gemspec" }} 21 | - v2-dependencies- 22 | 23 | - run: gem install bundler:2.0.2 24 | - run: bundle install --jobs=4 --retry=3 --path vendor/bundle 25 | 26 | - save_cache: 27 | paths: 28 | - ./vendor/bundle 29 | key: v2-dependencies-{{ checksum "graph_attack.gemspec" }} 30 | 31 | # Run tests! 32 | - run: 33 | name: run tests 34 | command: | 35 | mkdir /tmp/test-results 36 | TEST_FILES="$(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings)" 37 | 38 | bundle exec rspec --format progress \ 39 | --format RspecJunitFormatter \ 40 | --out /tmp/test-results/rspec.xml \ 41 | --format progress \ 42 | $TEST_FILES 43 | 44 | # Collect reports 45 | - store_test_results: 46 | path: /tmp/test-results 47 | - store_artifacts: 48 | path: /tmp/test-results 49 | destination: test-results 50 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: bundler 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "04:00" 8 | open-pull-requests-limit: 3 9 | rebase-strategy: disabled 10 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | name: on ruby ${{matrix.ruby}} 8 | runs-on: ubuntu-latest 9 | 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | ruby: ['3.0', '3.1', '3.2', '3.3', '3.4', head] 14 | 15 | services: 16 | redis: 17 | image: redis 18 | options: >- 19 | --health-cmd "redis-cli ping" 20 | --health-interval 10s 21 | --health-timeout 5s 22 | --health-retries 5 23 | ports: 24 | - 6379:6379 25 | 26 | steps: 27 | - name: Checkout repository 28 | uses: actions/checkout@v3 29 | 30 | - name: Set up Ruby 31 | uses: ruby/setup-ruby@v1 32 | with: 33 | ruby-version: ${{matrix.ruby}} 34 | 35 | - name: Install dependencies 36 | run: bundle install --jobs 4 --retry 3 37 | 38 | - name: RSpec 39 | run: bin/rspec 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | 10 | # rspec failure tracking 11 | .rspec_status 12 | 13 | # Ignore dev bundler resolution 14 | Gemfile.lock 15 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | plugins: 2 | - rubocop-rspec 3 | - rubocop-rake 4 | 5 | AllCops: 6 | TargetRubyVersion: 2.7 7 | DisplayCopNames: true 8 | NewCops: enable 9 | Exclude: 10 | - bin/rake 11 | - bin/rspec 12 | - bin/rubocop 13 | 14 | # Do not sort gems in Gemfile, since we are grouping them by functionality. 15 | Bundler/OrderedGems: 16 | Enabled: false 17 | 18 | # Trailing commas are required on multiline method arguments. 19 | Style/TrailingCommaInArguments: 20 | EnforcedStyleForMultiline: comma 21 | 22 | # Trailing commas are required in multiline arrays. 23 | Style/TrailingCommaInArrayLiteral: 24 | EnforcedStyleForMultiline: comma 25 | 26 | # Trailing commas are required in multiline hashes. 27 | Style/TrailingCommaInHashLiteral: 28 | EnforcedStyleForMultiline: comma 29 | 30 | # Allow indenting multiline chained operations. 31 | Layout/MultilineMethodCallIndentation: 32 | EnforcedStyle: indented 33 | 34 | Gemspec/RequiredRubyVersion: 35 | Enabled: false 36 | 37 | # Limit method length (default is 10). 38 | Metrics/MethodLength: 39 | Max: 15 40 | 41 | # Limit line length. 42 | Layout/LineLength: 43 | Max: 80 44 | 45 | # Allow ASCII comments (e.g "…"). 46 | Style/AsciiComments: 47 | Enabled: false 48 | 49 | # Do not comment the class we create, since the name should be self explanatory. 50 | Style/Documentation: 51 | Enabled: false 52 | 53 | # Do not verify the length of the blocks in specs. 54 | Metrics/BlockLength: 55 | Exclude: 56 | - spec/**/* 57 | 58 | # Prefer `== 0`, `< 0`, `> 0` to `zero?`, `negative?` or `positive?`, 59 | # since they don't exist before Ruby 2.3 or Rails 5 and can be ambiguous. 60 | Style/NumericPredicate: 61 | EnforcedStyle: comparison 62 | 63 | # Allow more expectations per example (default 1). 64 | RSpec/MultipleExpectations: 65 | Max: 5 66 | 67 | # Allow more group nesting (default 3) 68 | RSpec/NestedGroups: 69 | Max: 5 70 | 71 | # Allow longer examples (default 5) 72 | RSpec/ExampleLength: 73 | Max: 15 74 | 75 | Layout/EmptyLinesAroundAttributeAccessor: 76 | Enabled: true 77 | 78 | Layout/SpaceAroundMethodCallOperator: 79 | Enabled: true 80 | 81 | Lint/DeprecatedOpenSSLConstant: 82 | Enabled: true 83 | 84 | Lint/MixedRegexpCaptureTypes: 85 | Enabled: true 86 | 87 | Lint/RaiseException: 88 | Enabled: true 89 | 90 | Lint/StructNewOverride: 91 | Enabled: true 92 | 93 | Style/ExponentialNotation: 94 | Enabled: true 95 | 96 | Style/HashEachMethods: 97 | Enabled: true 98 | 99 | Style/HashTransformKeys: 100 | Enabled: true 101 | 102 | Style/HashTransformValues: 103 | Enabled: true 104 | 105 | Style/RedundantRegexpCharacterClass: 106 | Enabled: true 107 | 108 | Style/RedundantRegexpEscape: 109 | Enabled: true 110 | 111 | Style/SlicingWithRange: 112 | Enabled: true 113 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.4.2 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: ruby 3 | rvm: 4 | - 2.7.5 5 | - 3.1.2 6 | services: 7 | - redis-server 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | unreleased 2 | ---------- 3 | 4 | v2.4.0 5 | ------ 6 | 7 | Feature: 8 | - Accept ConnectionPool instances as a Redis client. 9 | 10 | Support: 11 | - Drop Ruby 2.7 support. 12 | 13 | v2.3.1 14 | ------ 15 | 16 | Fix: 17 | - Relax Ruby version constraint to allow Ruby 3.2. 18 | 19 | v2.3.0 20 | ------ 21 | 22 | Feature: 23 | - Add configuration for setting defaults. E.g.: 24 | 25 | ```rb 26 | GraphAttack.configure do |config| 27 | # config.threshold = 15 28 | # config.interval = 60 29 | # config.on = :ip 30 | # config.redis_client = Redis.new 31 | end 32 | ``` 33 | 34 | v2.2.0 35 | ------ 36 | 37 | Feature: 38 | - Skip throttling when rate limited field is nil (#19) 39 | 40 | ⚠️ Possibly breaking change: 41 | - If your app relied on `Redis.current`, please provide a `redis_client` option 42 | explicitly, since 43 | [`Redis.current` is deprecated](https://github.com/redis/redis-rb/commit/9745e22db65ac294be51ed393b584c0f8b72ae98) 44 | and will be removed in Redis 5. 45 | 46 | v2.1.0 47 | ------ 48 | 49 | Feature: 50 | - Add support to custom rate limited context key with the `on:` option. 51 | 52 | v2.0.0 53 | ------ 54 | 55 | Breaking changes: 56 | - Drop support for GraphQL legacy schema, please use GraphQL::Ruby's class-based 57 | syntax exclusively. 58 | 59 | Feature: 60 | - Support Ruby 3. 61 | 62 | v1.2.0 63 | ------ 64 | 65 | Feature: 66 | - New GraphAttack::RateLimit extension to be used in GraphQL::Ruby's class-based 67 | syntax. 68 | 69 | v1.1.0 70 | ------ 71 | 72 | Feature: 73 | - Add `redis_client` option to provide a custom Redis client. 74 | 75 | v1.0.0 76 | ------ 77 | 78 | First release! 79 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at sunny@sunfox.org. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | git_source(:github) { |repo_name| "https://github.com/#{repo_name}" } 6 | 7 | # Specify your gem's dependencies in graph_attack.gemspec 8 | gemspec 9 | 10 | # Development tasks runner. 11 | gem 'rake' 12 | 13 | # Testing framework. 14 | gem 'rspec' 15 | 16 | # CircleCI dependency to store spec results. 17 | gem 'rspec_junit_formatter' 18 | 19 | # Ruby code linter. 20 | gem 'rubocop' 21 | 22 | # RSpec extension for RuboCop. 23 | gem 'rubocop-rspec' 24 | 25 | # Rake extension for RuboCop 26 | gem 'rubocop-rake' 27 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2018 Fanny Cheung, Sunny Ripert 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GraphAttack 2 | 3 | [![Build Status](https://app.travis-ci.com/sunny/graph_attack.svg?branch=main)](https://app.travis-ci.com/sunny/graph_attack) 4 | 5 | GraphQL analyser for blocking & throttling. 6 | 7 | ## Usage 8 | 9 | This gem adds a method to limit access to your GraphQL fields by IP address: 10 | 11 | ```rb 12 | class QueryType < GraphQL::Schema::Object 13 | field :some_expensive_field, String, null: false do 14 | extension GraphAttack::RateLimit, threshold: 15, interval: 60 15 | end 16 | 17 | # … 18 | end 19 | ``` 20 | 21 | This would allow only 15 calls per minute by the same IP address. 22 | 23 | ## Requirements 24 | 25 | Requires [GraphQL Ruby](http://graphql-ruby.org/) and a running instance 26 | of [Redis](https://redis.io/). 27 | 28 | ## Installation 29 | 30 | Add these lines to your application’s `Gemfile`: 31 | 32 | ```ruby 33 | # GraphQL analyser for blocking & throttling by IP. 34 | gem "graph_attack" 35 | ``` 36 | 37 | And then execute: 38 | 39 | ```sh 40 | $ bundle 41 | ``` 42 | 43 | Finally, make sure you add the current user’s IP address as `ip:` to the 44 | GraphQL context. E.g.: 45 | 46 | ```rb 47 | class GraphqlController < ApplicationController 48 | def create 49 | result = ApplicationSchema.execute( 50 | params[:query], 51 | variables: params[:variables], 52 | context: { 53 | ip: request.ip, 54 | }, 55 | ) 56 | render json: result 57 | end 58 | end 59 | ``` 60 | 61 | If that key is `nil`, throttling will be disabled. 62 | 63 | ## Configuration 64 | 65 | ### Custom context key 66 | 67 | If you want to throttle using a different value than the IP address, you can 68 | choose which context key you want to use with the `on` option. E.g.: 69 | 70 | ```rb 71 | extension GraphAttack::RateLimit, 72 | threshold: 15, 73 | interval: 60, 74 | on: :client_id 75 | ``` 76 | 77 | ### Custom Redis client 78 | 79 | Use a custom Redis client instead of the default with the `redis_client` option: 80 | 81 | ```rb 82 | extension GraphAttack::RateLimit, 83 | threshold: 15, 84 | interval: 60, 85 | redis_client: Redis.new(url: "…") 86 | ``` 87 | 88 | ### Common configuration 89 | 90 | To have a default configuration for all rate-limited fields, you can create an 91 | initializer: 92 | 93 | ```rb 94 | GraphAttack.configure do |config| 95 | # config.threshold = 15 96 | # config.interval = 60 97 | # config.on = :ip 98 | # config.redis_client = Redis.new 99 | end 100 | ``` 101 | 102 | ## Development 103 | 104 | After checking out the repo, run `bin/setup` to install dependencies. Then, run 105 | `bin/rake` to run the tests and the linter. You can also run `bin/console` for 106 | an interactive prompt that will allow you to experiment. 107 | 108 | ## Versionning 109 | 110 | We use [SemVer](http://semver.org/) for versioning. For the versions available, 111 | see the tags on this repository. 112 | 113 | ## Releasing 114 | 115 | To release a new version, update the version number in `version.rb` and in the 116 | `CHANGELOG.md`. Update the `README.md` if there are missing segments, make sure 117 | tests and linting are pristine by calling `bundle && bin/rake`, then create a 118 | commit for this version, for example with: 119 | 120 | ```sh 121 | git add --patch 122 | git commit -m v`ruby -rbundler/setup -rgraph_attack/version -e "puts GraphAttack::VERSION"` 123 | ``` 124 | 125 | You can then run `bin/rake release`, which will assign a git tag, push using 126 | git, and push the gem to [rubygems.org](https://rubygems.org). 127 | 128 | ## Contributing 129 | 130 | Bug reports and pull requests are welcome on GitHub at 131 | https://github.com/sunny/graph_attack. This project is intended to be a safe, 132 | welcoming space for collaboration, and contributors are expected to adhere to 133 | the [Contributor Covenant](http://contributor-covenant.org) code of conduct. 134 | 135 | ## Code of Conduct 136 | 137 | Everyone interacting in the GraphAttack project’s codebases, issue trackers, 138 | chat rooms and mailing lists is expected to follow the 139 | [code of conduct](https://github.com/sunny/graph_attack/blob/main/CODE_OF_CONDUCT.md). 140 | 141 | ## License 142 | 143 | This project is licensed under the MIT License - see the 144 | [LICENSE.md](https://github.com/sunny/graph_attack/blob/main/LICENSE.md) 145 | file for details. 146 | 147 | ## Authors 148 | 149 | - [Fanny Cheung](https://github.com/Ynote) — [ynote.hk](https://ynote.hk) 150 | - [Sunny Ripert](https://github.com/sunny) — [sunfox.org](https://sunfox.org) 151 | 152 | ## Acknowledgments 153 | 154 | Hat tip to [Rack::Attack](https://github.com/kickstarter/rack-attack) for the 155 | the name. 156 | 157 | Sponsored by [Cults](https://cults3d.com). 158 | 159 | ![Cults. Logo](https://github.com/user-attachments/assets/3a51b90d-1033-4df5-a903-03668fc4b966) 160 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Bundler 4 | require 'bundler/gem_tasks' 5 | 6 | # RSpec 7 | require 'rspec/core/rake_task' 8 | RSpec::Core::RakeTask.new(:spec) 9 | 10 | # Rubocop 11 | require 'rubocop/rake_task' 12 | RuboCop::RakeTask.new(:rubocop) 13 | 14 | task default: %i[spec rubocop] 15 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'bundler/setup' 5 | require 'graph_attack' 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require 'pry' 12 | # Pry.start 13 | 14 | require 'irb' 15 | IRB.start(__FILE__) 16 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rake' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require 'pathname' 12 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', 13 | Pathname.new(__FILE__).realpath) 14 | 15 | bundle_binstub = File.expand_path('bundle', __dir__) 16 | 17 | if File.file?(bundle_binstub) 18 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 19 | load(bundle_binstub) 20 | else 21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 23 | end 24 | end 25 | 26 | require 'rubygems' 27 | require 'bundler/setup' 28 | 29 | load Gem.bin_path('rake', 'rake') 30 | -------------------------------------------------------------------------------- /bin/rspec: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rspec' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "pathname" 12 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 13 | Pathname.new(__FILE__).realpath) 14 | 15 | bundle_binstub = File.expand_path("../bundle", __FILE__) 16 | 17 | if File.file?(bundle_binstub) 18 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 19 | load(bundle_binstub) 20 | else 21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 23 | end 24 | end 25 | 26 | require "rubygems" 27 | require "bundler/setup" 28 | 29 | load Gem.bin_path("rspec-core", "rspec") 30 | -------------------------------------------------------------------------------- /bin/rubocop: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rubocop' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require 'pathname' 12 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', 13 | Pathname.new(__FILE__).realpath) 14 | 15 | bundle_binstub = File.expand_path('bundle', __dir__) 16 | 17 | if File.file?(bundle_binstub) 18 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 19 | load(bundle_binstub) 20 | else 21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 23 | end 24 | end 25 | 26 | require 'rubygems' 27 | require 'bundler/setup' 28 | 29 | load Gem.bin_path('rubocop', 'rubocop') 30 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /graph_attack.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path('lib', __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require 'graph_attack/version' 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = 'graph_attack' 9 | spec.version = GraphAttack::VERSION 10 | spec.authors = ['Fanny Cheung', 'Sunny Ripert'] 11 | spec.email = ['fanny@ynote.hk', 'sunny@sunfox.org'] 12 | 13 | spec.summary = 'GraphQL analyser for blocking & throttling' 14 | spec.description = 'GraphQL analyser for blocking & throttling' 15 | spec.homepage = 'https://github.com/sunny/graph_attack' 16 | 17 | spec.metadata['rubygems_mfa_required'] = 'true' 18 | 19 | spec.files = `git ls-files -z`.split("\x0").reject do |f| 20 | f.match(%r{^(test|spec|features)/}) 21 | end 22 | spec.bindir = 'exe' 23 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 24 | spec.require_paths = ['lib'] 25 | 26 | spec.required_ruby_version = '>= 2.5.7' 27 | 28 | # This gem is an analyser for the GraphQL ruby gem. 29 | spec.add_dependency 'graphql', '>= 1.7.9' 30 | 31 | # A Redis-backed rate limiter. 32 | spec.add_dependency 'ratelimit', '>= 1.0.4' 33 | end 34 | -------------------------------------------------------------------------------- /lib/graph_attack.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'ratelimit' 4 | require 'graphql' 5 | require 'graphql/tracing' 6 | 7 | require 'graph_attack/version' 8 | 9 | require 'graph_attack/configuration' 10 | require 'graph_attack/error' 11 | require 'graph_attack/rate_limit' 12 | require 'graph_attack/rate_limited' 13 | -------------------------------------------------------------------------------- /lib/graph_attack/configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GraphAttack 4 | # Store the config 5 | class Configuration 6 | # Number of calls allowed. 7 | attr_accessor :threshold 8 | 9 | # Time interval in seconds. 10 | attr_accessor :interval 11 | 12 | # Key on the context on which to differentiate users. 13 | attr_accessor :on 14 | 15 | # Use a custom Redis client. 16 | attr_accessor :redis_client 17 | 18 | def initialize 19 | @threshold = nil 20 | @interval = nil 21 | @on = :ip 22 | @redis_client = Redis.new 23 | end 24 | end 25 | 26 | class << self 27 | attr_writer :configuration 28 | 29 | def configuration 30 | @configuration ||= Configuration.new 31 | end 32 | 33 | def configure 34 | yield(configuration) 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/graph_attack/error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GraphAttack 4 | class Error < StandardError; end 5 | end 6 | -------------------------------------------------------------------------------- /lib/graph_attack/rate_limit.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GraphAttack 4 | class RateLimit < GraphQL::Schema::FieldExtension 5 | def resolve(object:, arguments:, **_rest) 6 | rate_limited_field = object.context[on] 7 | 8 | unless object.context.key?(on) 9 | raise GraphAttack::Error, "Missing :#{on} key on the GraphQL context" 10 | end 11 | 12 | if rate_limited_field && calls_exceeded_on_query?(rate_limited_field) 13 | return RateLimited.new('Query rate limit exceeded') 14 | end 15 | 16 | yield(object, arguments) 17 | end 18 | 19 | private 20 | 21 | def key 22 | suffix = "-#{on}" if on != :ip 23 | 24 | "graphql-query-#{field.name}#{suffix}" 25 | end 26 | 27 | def calls_exceeded_on_query?(rate_limited_field) 28 | with_redis_client do |redis_client| 29 | rate_limit = Ratelimit.new(rate_limited_field, redis: redis_client) 30 | rate_limit.add(key) 31 | rate_limit.exceeded?(key, threshold: threshold, interval: interval) 32 | end 33 | end 34 | 35 | def threshold 36 | options[:threshold] || 37 | GraphAttack.configuration.threshold || 38 | raise( 39 | GraphAttack::Error, 40 | 'Missing "threshold:" option on the GraphAttack::RateLimit extension', 41 | ) 42 | end 43 | 44 | def interval 45 | options[:interval] || 46 | GraphAttack.configuration.interval || 47 | raise( 48 | GraphAttack::Error, 49 | 'Missing "interval:" option on the GraphAttack::RateLimit extension', 50 | ) 51 | end 52 | 53 | def with_redis_client(&block) 54 | client = options[:redis_client] || GraphAttack.configuration.redis_client 55 | if client.respond_to?(:then) 56 | client.then(&block) 57 | else 58 | block.call(client) 59 | end 60 | end 61 | 62 | def on 63 | options[:on] || GraphAttack.configuration.on 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/graph_attack/rate_limited.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GraphAttack 4 | class RateLimited < GraphQL::AnalysisError; end 5 | end 6 | -------------------------------------------------------------------------------- /lib/graph_attack/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GraphAttack 4 | VERSION = '2.4.0' 5 | end 6 | -------------------------------------------------------------------------------- /spec/graph_attack/configuration_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe GraphAttack::Configuration do 4 | let(:configuration) { GraphAttack.configuration } 5 | 6 | describe '.configuration' do 7 | it 'assigns defaults' do 8 | expect(configuration).to be_a(described_class) 9 | expect(configuration.threshold).to be_nil 10 | expect(configuration.interval).to be_nil 11 | expect(configuration.on).to eq(:ip) 12 | expect(configuration.redis_client).to be_a(Redis) 13 | end 14 | end 15 | 16 | describe '.configure' do 17 | let(:redis) { instance_double Redis } 18 | 19 | after do 20 | GraphAttack.configuration = described_class.new 21 | end 22 | 23 | it 'can set new values' do 24 | GraphAttack.configure do |c| 25 | c.threshold = 99 26 | c.interval = 30 27 | c.on = :client_token 28 | c.redis_client = redis 29 | end 30 | 31 | expect(configuration.threshold).to eq(99) 32 | expect(configuration.interval).to eq(30) 33 | expect(configuration.on).to eq(:client_token) 34 | expect(configuration.redis_client).to eq(redis) 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/graph_attack/rate_limit_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dummy 4 | CUSTOM_REDIS_CLIENT = Redis.new 5 | POOLED_REDIS_CLIENT = ConnectionPool.new(size: 5, timeout: 5) do 6 | Redis.new 7 | end 8 | 9 | class QueryType < GraphQL::Schema::Object 10 | field :inexpensive_field, String, null: false 11 | 12 | field :expensive_field, String, null: false do 13 | extension GraphAttack::RateLimit, threshold: 5, interval: 15 14 | end 15 | 16 | field :expensive_field2, String, null: false do 17 | extension GraphAttack::RateLimit, threshold: 10, interval: 15 18 | end 19 | 20 | field :field_with_custom_redis_client, String, null: false do 21 | extension GraphAttack::RateLimit, 22 | threshold: 10, 23 | interval: 15, 24 | redis_client: CUSTOM_REDIS_CLIENT 25 | end 26 | 27 | field :field_with_pooled_redis_client, String, null: false do 28 | extension GraphAttack::RateLimit, 29 | threshold: 10, 30 | interval: 15, 31 | redis_client: POOLED_REDIS_CLIENT 32 | end 33 | 34 | field :field_with_on_option, String, null: false do 35 | extension GraphAttack::RateLimit, 36 | threshold: 10, 37 | interval: 15, 38 | on: :client_id 39 | end 40 | 41 | field :field_with_defaults, String, null: false do 42 | extension GraphAttack::RateLimit 43 | end 44 | 45 | def inexpensive_field 46 | 'result' 47 | end 48 | 49 | def expensive_field 50 | 'result' 51 | end 52 | 53 | def expensive_field2 54 | 'result' 55 | end 56 | 57 | def field_with_custom_redis_client 58 | 'result' 59 | end 60 | 61 | def field_with_pooled_redis_client 62 | 'result' 63 | end 64 | 65 | def field_with_on_option 66 | 'result' 67 | end 68 | 69 | def field_with_defaults 70 | 'result' 71 | end 72 | end 73 | 74 | class Schema < GraphQL::Schema 75 | query QueryType 76 | end 77 | end 78 | 79 | RSpec.describe GraphAttack::RateLimit do 80 | let(:schema) { Dummy::Schema } 81 | let(:redis) { Redis.new } 82 | let(:context) { { ip: '99.99.99.99' } } 83 | 84 | before do 85 | # Clean up after ratelimit gem. 86 | redis.then do |client| 87 | client.scan_each(match: 'ratelimit:*') { |key| client.del(key) } 88 | end 89 | 90 | # Clean up configuration changes. 91 | GraphAttack.configuration = GraphAttack::Configuration.new 92 | end 93 | 94 | context 'when context has IP key' do 95 | describe 'fields without rate limiting' do 96 | let(:query) { '{ inexpensiveField }' } 97 | 98 | it 'returns data' do 99 | result = schema.execute(query, context: context) 100 | 101 | expect(result).not_to have_key('errors') 102 | expect(result['data']).to eq('inexpensiveField' => 'result') 103 | end 104 | 105 | it 'does not insert rate limits in redis' do 106 | schema.execute(query, context: context) 107 | 108 | expect(redis.scan_each(match: 'ratelimit:*').count).to eq(0) 109 | end 110 | end 111 | 112 | describe 'fields with rate limiting' do 113 | let(:query) { '{ expensiveField }' } 114 | 115 | it 'inserts rate limits in redis' do 116 | schema.execute(query, context: context) 117 | 118 | key = 'ratelimit:99.99.99.99:graphql-query-expensiveField' 119 | expect(redis.scan_each(match: key).count).to eq(1) 120 | end 121 | 122 | it 'returns data until rate limit is exceeded' do 123 | 4.times do 124 | result = schema.execute(query, context: context) 125 | 126 | expect(result).not_to have_key('errors') 127 | expect(result['data']).to eq('expensiveField' => 'result') 128 | end 129 | end 130 | 131 | context 'when rate limit is exceeded' do 132 | before do 133 | 4.times do 134 | schema.execute(query, context: context) 135 | end 136 | end 137 | 138 | it 'returns an error' do 139 | result = schema.execute(query, context: context) 140 | 141 | expected_error = { 142 | 'locations' => [{ 'column' => 3, 'line' => 1 }], 143 | 'message' => 'Query rate limit exceeded', 144 | 'path' => ['expensiveField'], 145 | } 146 | expect(result['errors']).to eq([expected_error]) 147 | expect(result['data']).to be_nil 148 | end 149 | 150 | context 'when on a different IP' do 151 | let(:context2) { { ip: '203.0.113.43' } } 152 | 153 | it 'does not return an error' do 154 | result = schema.execute(query, context: context2) 155 | 156 | expect(result).not_to have_key('errors') 157 | expect(result['data']).to eq('expensiveField' => 'result') 158 | end 159 | end 160 | end 161 | end 162 | 163 | describe 'fields with defaults' do 164 | let(:query) { '{ fieldWithDefaults }' } 165 | 166 | context 'with no defaults' do 167 | it 'raises' do 168 | expect do 169 | schema.execute(query, context: context) 170 | end.to raise_error(GraphAttack::Error, /Missing "threshold:" option/) 171 | end 172 | end 173 | 174 | context 'with defaults' do 175 | let(:new_redis) { Redis.new } 176 | let(:context) { { client_token: 'abc89' } } 177 | 178 | before do 179 | GraphAttack.configure do |c| 180 | c.threshold = 3 181 | c.interval = 30 182 | c.on = :client_token 183 | c.redis_client = new_redis 184 | end 185 | end 186 | 187 | after do 188 | new_redis.scan_each(match: 'ratelimit:*') { |key| new_redis.del(key) } 189 | end 190 | 191 | it 'inserts rate limits using the defaults' do 192 | schema.execute(query, context: context) 193 | 194 | key = 'ratelimit:abc89:graphql-query-fieldWithDefaults-client_token' 195 | expect(new_redis.scan_each(match: key).count).to eq(1) 196 | end 197 | 198 | it 'returns an error when the default rate limit is exceeded' do 199 | 2.times do 200 | result = schema.execute(query, context: context) 201 | 202 | expect(result).not_to have_key('errors') 203 | expect(result['data']).to eq('fieldWithDefaults' => 'result') 204 | end 205 | 206 | result = schema.execute(query, context: context) 207 | 208 | expected_error = { 209 | 'locations' => [{ 'column' => 3, 'line' => 1 }], 210 | 'message' => 'Query rate limit exceeded', 211 | 'path' => ['fieldWithDefaults'], 212 | } 213 | expect(result['errors']).to eq([expected_error]) 214 | expect(result['data']).to be_nil 215 | end 216 | end 217 | end 218 | 219 | describe 'several fields with rate limiting' do 220 | let(:query) { '{ expensiveField expensiveField2 }' } 221 | 222 | context 'when one rate limit is exceeded' do 223 | let(:expected_error) do 224 | { 225 | 'locations' => [{ 'column' => 3, 'line' => 1 }], 226 | 'message' => 'Query rate limit exceeded', 227 | 'path' => ['expensiveField'], 228 | } 229 | end 230 | 231 | before do 232 | 5.times do 233 | schema.execute(query, context: context) 234 | end 235 | end 236 | 237 | it 'returns an error message with only the first field' do 238 | result = schema.execute(query, context: context) 239 | 240 | expect(result['errors']).to eq([expected_error]) 241 | expect(result['data']).to be_nil 242 | end 243 | end 244 | 245 | context 'when both rate limits are exceeded' do 246 | let(:expected_errors) do 247 | [ 248 | { 249 | 'locations' => [{ 'column' => 3, 'line' => 1 }], 250 | 'message' => 'Query rate limit exceeded', 251 | 'path' => ['expensiveField'], 252 | }, 253 | { 254 | 'locations' => [{ 'column' => 18, 'line' => 1 }], 255 | 'message' => 'Query rate limit exceeded', 256 | 'path' => ['expensiveField2'], 257 | }, 258 | ] 259 | end 260 | 261 | before do 262 | 10.times do 263 | schema.execute(query, context: context) 264 | end 265 | end 266 | 267 | it 'returns an error on both' do 268 | result = schema.execute(query, context: context) 269 | 270 | expect(result['errors']).to eq(expected_errors) 271 | expect(result['data']).to be_nil 272 | end 273 | end 274 | end 275 | 276 | context 'with a custom redis client field' do 277 | let(:redis) { Dummy::CUSTOM_REDIS_CLIENT } 278 | let(:query) { '{ fieldWithCustomRedisClient }' } 279 | 280 | it 'inserts rate limits in the custom redis client' do 281 | schema.execute(query, context: context) 282 | 283 | key = 'ratelimit:99.99.99.99:graphql-query-fieldWithCustomRedisClient' 284 | expect(redis.scan_each(match: key).count).to eq(1) 285 | end 286 | end 287 | 288 | context 'with a pooled redis client field' do 289 | let(:redis) { Dummy::POOLED_REDIS_CLIENT } 290 | let(:query) { '{ fieldWithPooledRedisClient }' } 291 | 292 | it 'inserts rate limits in the custom redis client' do 293 | schema.execute(query, context: context) 294 | 295 | key = 'ratelimit:99.99.99.99:graphql-query-fieldWithPooledRedisClient' 296 | expect(redis.then { _1.scan_each(match: key).count }).to eq(1) 297 | end 298 | end 299 | 300 | describe 'fields with the on option' do 301 | let(:query) { '{ fieldWithOnOption }' } 302 | let(:context) { { client_id: '0cca3dfc-6638' } } 303 | 304 | it 'inserts rate limits in redis' do 305 | schema.execute(query, context: context) 306 | 307 | key = 'ratelimit:0cca3dfc-6638:graphql-query-fieldWithOnOption-' \ 308 | 'client_id' 309 | expect(redis.scan_each(match: key).count).to eq(1) 310 | end 311 | 312 | it 'returns data until rate limit is exceeded' do 313 | 9.times do 314 | result = schema.execute(query, context: context) 315 | 316 | expect(result).not_to have_key('errors') 317 | expect(result['data']).to eq('fieldWithOnOption' => 'result') 318 | end 319 | end 320 | 321 | context 'when rate limit is exceeded' do 322 | before do 323 | 9.times do 324 | schema.execute(query, context: context) 325 | end 326 | end 327 | 328 | it 'returns an error' do 329 | result = schema.execute(query, context: context) 330 | 331 | expected_error = { 332 | 'locations' => [{ 'column' => 3, 'line' => 1 }], 333 | 'message' => 'Query rate limit exceeded', 334 | 'path' => ['fieldWithOnOption'], 335 | } 336 | expect(result['errors']).to eq([expected_error]) 337 | expect(result['data']).to be_nil 338 | end 339 | 340 | context 'when on a different :on value' do 341 | let(:context2) do 342 | { client_id: '25be1c42-0cf1-424e-acfe-d8d31939a1c0' } 343 | end 344 | 345 | it 'does not return an error' do 346 | result = schema.execute(query, context: context2) 347 | 348 | expect(result).not_to have_key('errors') 349 | expect(result['data']).to eq('fieldWithOnOption' => 'result') 350 | end 351 | end 352 | end 353 | end 354 | end 355 | 356 | context 'when context has not IP key' do 357 | let(:query) { '{ expensiveField }' } 358 | 359 | it 'returns an error' do 360 | expect { schema.execute(query) }.to raise_error( 361 | GraphAttack::Error, /Missing :ip key on the GraphQL context/ 362 | ) 363 | end 364 | end 365 | end 366 | -------------------------------------------------------------------------------- /spec/graph_attack_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe GraphAttack do 4 | it 'has a version number' do 5 | expect(GraphAttack::VERSION).not_to be_nil 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/setup' 4 | require 'graph_attack' 5 | 6 | RSpec.configure do |config| 7 | # Enable flags like --only-failures and --next-failure 8 | config.example_status_persistence_file_path = '.rspec_status' 9 | 10 | # Disable RSpec exposing methods globally on `Module` and `main` 11 | config.disable_monkey_patching! 12 | end 13 | --------------------------------------------------------------------------------