Query complexity check causes wrong GraphQL error response
mgruner opened this issue · 8 comments
Describe the bug
In case of exceptions raised by input types, query complexity is calculated wrong, causing misleading error responses.
This is seems to be quite similar to #4666, but still happens.
Versions
graphql
version: 2.3.10
rails
(or other framework):
other applicable versions (graphql-batch
, etc)
GraphQL query
Example GraphQL query and response (if query execution is involved)
query ticketWithMentionLimit(
$ticketId: ID
$ticketInternalId: Int
$ticketNumber: String
$mentionsCount: Int = null
) {
ticket(
ticket: {
ticketId: $ticketId
ticketInternalId: $ticketInternalId
ticketNumber: $ticketNumber
}
) {
...ticketAttributes
createArticleType {
id
name
}
mentions(first: $mentionsCount) {
totalCount
edges {
node {
...ticketMention
}
cursor
}
}
}
}
Expected behavior
When called for a nonexisting ticket, the query should return a proper error about the input validation failure.
Actual behavior
When called for a nonexisting ticket, there is a GraphQL error response about exceeded max query complexity, instead of the expected authorization error.
Additional context
We suspect a similar problem like #4666: input validation failure might cause the mentionsCount limit parameter to be ignored, falling back to the default page size, which in turn triggers the complexity error.
@rmosolgo it looks like it is really the same problem as in #4666. There is one difference: this time, it's not a graphql authorization error, but instead simply a ActiveRecord::RecordNotFound
that causes the input validation. Your change #4868 only looks for authorization errors though. But there could be other kinds of errors happening during the input validation phase which cause the complexity miscalculation as well. Thanks for your attention!
Hey, thanks for all the details and sorry for the trouble. I'll take a look soon!
👋 I wrote a quick replication script for this issue:
Max complexity + loading non-existent records with ActiveRecord
require "bundler/inline"
gemfile do
gem "graphql"
gem "sqlite3", "~>1.4"
gem "activerecord", require: "active_record"
end
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:" )
ActiveRecord::Schema.define do
create_table :tickets do |t|
t.string(:subject)
end
end
class Ticket < ActiveRecord::Base; end
Ticket.create!(subject: "Thing doesn't work")
class MySchema < GraphQL::Schema
class Mention < GraphQL::Schema::Object
field :content, String
end
class Ticket < GraphQL::Schema::Object
field :subject, String
field :mentions, Mention.connection_type
def mentions = []
end
class TicketInfo < GraphQL::Schema::InputObject
argument :ticket_id, ID, loads: Ticket
end
class Query < GraphQL::Schema::Object
field :ticket, Ticket do
argument :ticket_info, TicketInfo
end
def ticket(ticket_info:)
ticket_info[:ticket]
end
end
max_complexity 10
query(Query)
def self.object_from_id(id, ctx)
::Ticket.find(id)
end
def self.resolve_type(abs_t, obj, ctx)
Ticket
end
end
query_str = <<~GRAPHQL
query($ticketId: ID!, $first: Int!) {
ticket(ticketInfo: { ticketId: $ticketId }) {
subject
mentions(first: $first) { nodes { content } }
}
}
GRAPHQL
# over max complexity:
pp MySchema.execute(query_str, variables: { ticketId: "1", first: 100 }).to_h
# {"errors"=>[{"message"=>"Query has complexity of 104, which exceeds max complexity of 10"}]}
pp MySchema.execute(query_str, variables: { ticketId: "2", first: 100 }).to_h
# {"errors"=>[{"message"=>"Query has complexity of 104, which exceeds max complexity of 10"}]}
# within max complexity:
pp MySchema.execute(query_str, variables: { ticketId: "1", first: 1 }).to_h
# {"data"=>{"ticket"=>{"subject"=>"Thing doesn't work", "mentions"=>{"nodes"=>[]}}}}
pp MySchema.execute(query_str, variables: { ticketId: "2", first: 1 }).to_h
# /Users/rmosolgo/.rbenv/versions/3.1.1/lib/ruby/gems/3.1.0/gems/activerecord-7.1.3.2/lib/active_record/core.rb:253:in `find': Couldn't find Ticket with 'id'=2 (ActiveRecord::RecordNotFound)
# from complexity_test.rb:46:in `object_from_id'
# from /Users/rmosolgo/.rbenv/versions/3.1.1/lib/ruby/gems/3.1.0/gems/graphql-2.3.4/lib/graphql/schema/member/has_arguments.rb:363:in `object_from_id'
# from /Users/rmosolgo/.rbenv/versions/3.1.1/lib/ruby/gems/3.1.0/gems/graphql-2.3.4/lib/graphql/schema/member/has_arguments.rb:371:in `load_application_object'
# from /Users/rmosolgo/.rbenv/versions/3.1.1/lib/ruby/gems/3.1.0/gems/graphql-2.3.4/lib/graphql/schema/member/has_arguments.rb:375:in `load_and_authorize_application_object'
# from /Users/rmosolgo/.rbenv/versions/3.1.1/lib/ruby/gems/3.1.0/gems/graphql-2.3.4/lib/graphql/schema/argument.rb:332:in `load_and_authorize_value'
# from /Users/rmosolgo/.rbenv/versions/3.1.1/lib/ruby/gems/3.1.0/gems/graphql-2.3.4/lib/graphql/schema/argument.rb:282:in `block in coerce_into_values'
To me, it looks like it's working as expected:
- When the incoming query exceeds
max_complexity
, it is rejected by the schema before any objects are loaded by ID. (This is because the complexity calculation doesn't require any loaded objects -- it doesn't make any DB calls.) - When the incoming query is under
max_complexity
...- if the record is found, a GraphQL result is returned
- if loading the record raises
ActiveRecord::NotFound
, then that error propagates to the caller (crashes the query)
So, I don't know if/where any work is required in GraphQL-Ruby. Could you help me debug further? Here are some ways to investigate:
- Do you notice anything missing in my script, compared to your app?
- Can you confirm that
ActiveRecord::NotFound
is being raised, then silently rescued? For example, can you add arescue
to yourdef self.object_from_id
method, thenputs err.backtrace
? That backtrace would really help me understand what's going on when the error is raised. - Does your schema have any special handling for
ActiveRecord::NotFound
? It might be a schemarescue_from
handler, or it might be custom logic in resolver code. - In the example above, is the incoming query actually above the
max_complexity
? If you use the ID of a ticket that actually exists, does it still return a complexity error? If you keep using the invalid ID, but remove all the other fields from the query, what response do you get?
Let me know what you find!
If you get a chance to look into those questions, please let me know what you find. I'll close it since I don't have any plans to investigate further until we get more info 👍
@rmosolgo I'm sorry for the delay. Just returned from vacation yesterday. Will analyze this and get back to you asap.
@rmosolgo I managed to create a reproduction script.
modified max complexity check script
require 'bundler/inline'
gemfile do
gem 'graphql'
gem 'sqlite3', '~>1.4'
gem 'activerecord', require: 'active_record'
end
ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:')
ActiveRecord::Schema.define do
create_table :mentions do |t|
t.string(:content)
t.belongs_to :ticket
end
create_table :tickets do |t|
t.string(:subject)
end
end
class Mention < ActiveRecord::Base; end
class Ticket < ActiveRecord::Base
has_many :mentions
end
ticket = Ticket.create!(subject: "Thing doesn't work")
Mention.create!(content: "Thing doesn't work", ticket_id: ticket.id)
class MySchema < GraphQL::Schema
class Mention < GraphQL::Schema::Object
field :content, String
end
class Ticket < GraphQL::Schema::Object
field :subject, String
field :mentions, Mention.connection_type
def mentions = []
end
class TicketInfo < GraphQL::Schema::InputObject
argument :ticket_id, ID, loads: Ticket
end
class Query < GraphQL::Schema::Object
field :mentions, Mention.connection_type do
argument :ticket_info, TicketInfo
end
def mentions(ticket_info:)
ticket_info.ticket.mentions
end
end
default_page_size 50
max_complexity 10
query(Query)
def self.object_from_id(id, ctx)
::Ticket.find(id)
end
def self.resolve_type(abs_t, obj, ctx)
Ticket
end
RETHROWABLE_ERRORS = [GraphQL::ExecutionError, ArgumentError, IndexError, NameError, RangeError, RegexpError, SystemCallError, ThreadError, TypeError, ZeroDivisionError].freeze
# Post-process errors and enrich them with meta information for processing on the client side.
rescue_from(StandardError) do |err, _obj, _args, ctx, field|
# Re-throw built-in errors that point to programming errors rather than problems with input or data - causes GraphQL processing to be aborted.
RETHROWABLE_ERRORS.each do |klass|
raise err if err.instance_of?(klass)
end
# We need to throw an ExecutionError, all others would cause the GraphQL processing to die.
raise GraphQL::ExecutionError, err.message
end
end
query_str = <<~GRAPHQL
query queryOne($ticketId: ID!, $first: Int!) {
mentions(ticketInfo: { ticketId: $ticketId }, first: $first) {
nodes { content }
}
}
GRAPHQL
# over max complexity:
pp MySchema.execute(query_str, variables: { ticketId: '1', first: 100 }).to_h
# {"errors"=>[{"message"=>"Query has complexity of 104, which exceeds max complexity of 10"}]}
pp MySchema.execute(query_str, variables: { ticketId: '2', first: 100 }).to_h
# {"errors"=>[{"message"=>"Query has complexity of 52, which exceeds max complexity of 10"}]}
# within max complexity:
pp MySchema.execute(query_str, variables: { ticketId: '1', first: 1 }).to_h
# {"data"=>{"mentions"=>{"nodes"=>[{"content"=>"Thing doesn't work"}]}}}
pp MySchema.execute(query_str, variables: { ticketId: '2', first: 1 }).to_h
# {"errors"=>[{"message"=>"Query has complexity of 52, which exceeds max complexity of 10"}]}
My initial query of the bug report was incorrect, sorry for that. It happens when a connection type has an input type that raises the ActiveRecord::RecordNotFound
which is in turn wrapped in a GraphQL::ExecutionError
by a rescue handler. Then GraphQL::Schema::Field#calculate_complexity
will receive the error instead of the connection type field arguments, so that first
/ last
are not present and cannot be considered. Then it falls back to the default page size, causing the complexity error.
Unfortunately I have no soution proposal, as modifying the line elsif arguments.is_a?(GraphQL::UnauthorizedError)
to also consider GraphQL::ExecutionError
does not seem to be a solution, as it causes the processing to interrupt with Exception.
Does this make sense? Thank you very much for considering.
Hey, thanks for those details. I was able to replicate the issue and work up a patch in #5071
Thank you very much!