Skip to content

Commit aa0eff7

Browse files
committed
Merge branch 'master' into fix-cve-2025-27407
2 parents acc6335 + e95c424 commit aa0eff7

File tree

22 files changed

+318
-100
lines changed

22 files changed

+318
-100
lines changed

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,24 @@
1010

1111
### Bug fixes
1212

13+
# 2.4.12 (11 Mar 2025)
14+
15+
### Breaking changes
16+
17+
- Remove `InvalidNullError#value` which is always `nil` #5256
18+
19+
### New features
20+
21+
- `validate_timeout` is 3 seconds by default #5258
22+
23+
### Bug fixes
24+
25+
- New Relic: reimplement skipping scalars by default #5271
26+
- Resolver: revert inheriting overridden `graphql_name` #5260
27+
- Analysis: manually implement timeout to handle I/O better #5263
28+
- Parser: properly handle extra token at the end of the query string #5267
29+
- Validation: fix conflicting aliases inside fragment #5268
30+
1331
# 2.4.11 (28 Feb 2025)
1432

1533
### New features

cop/development/trace_methods_cop.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ def on_module(node)
7373
# Not really necessary for making a good trace:
7474
:lex, :analyze_query, :execute_query, :execute_query_lazy,
7575
# Only useful for isolated event tracking:
76+
:begin_dataloader, :end_dataloader,
7677
:dataloader_fiber_exit, :dataloader_spawn_execution_fiber, :dataloader_spawn_source_fiber
7778
]
7879
missing_defs.each do |missing_def|

guides/authorization/visibility.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,8 +151,8 @@ For big schemas, this can be a worthwhile speed-up.
151151
- `Visibility` speeds up Rails app boot because it doesn't require all types to be loaded during boot and only loads types as they are used by queries.
152152
- `Visibility` supports predefined, reusable visibility profiles which speeds up queries using complicated `visible?` checks.
153153
- `Visibility` hides types differently in a few edge cases:
154-
- Previously, `Warden` hide interface and union types which had no possible types. `Visibility` doesn't check possible types (in order to support performance improvements), so those types must return `false` for `visible?` in the same cases where all possible types were hidden. Otherwise, that interface or union type will be visible but have no possible types.
155-
- Some other thing, see TODO
154+
- Previously, `Warden` hid interface and union types which had no possible types. `Visibility` doesn't check possible types (in order to support performance improvements), so those types must return `false` for `visible?` in the same cases where all possible types were hidden. Otherwise, that interface or union type will be visible but have no possible types.
155+
- When an object type is connected to the schema as a field return type or a union member, and also implements and interface, if the object type's _other_ connection(s) to the schema are hidden, then it won't appear as an implementer of that interface unless it's registered with `orphan_types` (either by the schema or interface). `Warden` used a "global" map of types so it could discover object types in this case, but `Visibility` doesn't have that global map. (Since time of writing, `Visibility` _does_ have some global type tracking, so maybe this could be fixed.)
156156
- When `Visibility` is used, several (Ruby-level) Schema introspection methods don't work because the caches they draw on haven't been calculated (`Schema.references_to`, `Schema.union_memberships`). If you're using these, please get in touch so that we can find a way forward.
157157

158158
### Migration Mode

guides/queries/timeout.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,15 +67,21 @@ end
6767

6868
Queries can originate from a user, and may be crafted in a manner to take a long time to validate against the schema.
6969

70-
It is possible to limit how many seconds the static validation rules and analysers are allowed to run before returning a validation timeout error. The default is no timeout.
70+
It is possible to limit how many seconds the static validation rules and analysers are allowed to run before returning a validation timeout error. By default, validation and query analysis have a 3-second timeout. You can customize this timeout or disable it completely:
7171

7272
For example:
7373

7474
```ruby
75+
# Customize timeout (in seconds)
7576
class MySchema < GraphQL::Schema
7677
# Applies to static validation and query analysis
7778
validate_timeout 10
7879
end
80+
81+
# OR disable timeout completely
82+
class MySchema < GraphQL::Schema
83+
validate_timeout nil
84+
end
7985
```
8086

8187
**Note:** This configuration uses Ruby's built-in `Timeout` API, which can interrupt IO calls mid-flight, resulting in [very weird bugs](https://www.mikeperham.com/2015/05/08/timeout-rubys-most-dangerous-api/). None of GraphQL-Ruby's validators make IO calls but if you want to use this configuration and you have custom static validators that make IO calls, open an issue to discuss implementing this in an IO-safe way.

lib/graphql/analysis.rb

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,16 @@
66
require "graphql/analysis/max_query_complexity"
77
require "graphql/analysis/query_depth"
88
require "graphql/analysis/max_query_depth"
9-
require "timeout"
10-
119
module GraphQL
1210
module Analysis
1311
AST = self
12+
13+
class TimeoutError < AnalysisError
14+
def initialize(...)
15+
super("Timeout on validation of query")
16+
end
17+
end
18+
1419
module_function
1520
# Analyze a multiplex, and all queries within.
1621
# Multiplex analyzers are ran for all queries, keeping state.
@@ -61,13 +66,11 @@ def analyze_query(query, analyzers, multiplex_analyzers: [])
6166
if !analyzers_to_run.empty?
6267
visitor = GraphQL::Analysis::Visitor.new(
6368
query: query,
64-
analyzers: analyzers_to_run
69+
analyzers: analyzers_to_run,
70+
timeout: query.validate_timeout_remaining,
6571
)
6672

67-
# `nil` or `0` causes no timeout
68-
Timeout::timeout(query.validate_timeout_remaining) do
69-
visitor.visit
70-
end
73+
visitor.visit
7174

7275
if !visitor.rescued_errors.empty?
7376
return visitor.rescued_errors
@@ -79,8 +82,8 @@ def analyze_query(query, analyzers, multiplex_analyzers: [])
7982
[]
8083
end
8184
end
82-
rescue Timeout::Error
83-
[GraphQL::AnalysisError.new("Timeout on validation of query")]
85+
rescue TimeoutError => err
86+
[err]
8487
rescue GraphQL::UnauthorizedError, GraphQL::ExecutionError
8588
# This error was raised during analysis and will be returned the client before execution
8689
[]

lib/graphql/analysis/visitor.rb

Lines changed: 35 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ module Analysis
1010
#
1111
# @see {GraphQL::Analysis::Analyzer} AST Analyzers for queries
1212
class Visitor < GraphQL::Language::StaticVisitor
13-
def initialize(query:, analyzers:)
13+
def initialize(query:, analyzers:, timeout:)
1414
@analyzers = analyzers
1515
@path = []
1616
@object_types = []
@@ -24,6 +24,11 @@ def initialize(query:, analyzers:)
2424
@types = query.types
2525
@response_path = []
2626
@skip_stack = [false]
27+
@timeout_time = if timeout
28+
Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_second) + timeout
29+
else
30+
Float::INFINITY
31+
end
2732
super(query.selected_operation)
2833
end
2934

@@ -73,21 +78,17 @@ def response_path
7378
module_eval <<-RUBY, __FILE__, __LINE__
7479
def call_on_enter_#{node_type}(node, parent)
7580
@analyzers.each do |a|
76-
begin
77-
a.on_enter_#{node_type}(node, parent, self)
78-
rescue AnalysisError => err
79-
@rescued_errors << err
80-
end
81+
a.on_enter_#{node_type}(node, parent, self)
82+
rescue AnalysisError => err
83+
@rescued_errors << err
8184
end
8285
end
8386
8487
def call_on_leave_#{node_type}(node, parent)
8588
@analyzers.each do |a|
86-
begin
87-
a.on_leave_#{node_type}(node, parent, self)
88-
rescue AnalysisError => err
89-
@rescued_errors << err
90-
end
89+
a.on_leave_#{node_type}(node, parent, self)
90+
rescue AnalysisError => err
91+
@rescued_errors << err
9192
end
9293
end
9394
@@ -96,6 +97,7 @@ def call_on_leave_#{node_type}(node, parent)
9697
# rubocop:enable Development/NoEvalCop
9798

9899
def on_operation_definition(node, parent)
100+
check_timeout
99101
object_type = @schema.root_type_for_operation(node.operation_type)
100102
@object_types.push(object_type)
101103
@path.push("#{node.operation_type}#{node.name ? " #{node.name}" : ""}")
@@ -106,31 +108,27 @@ def on_operation_definition(node, parent)
106108
@path.pop
107109
end
108110

109-
def on_fragment_definition(node, parent)
110-
on_fragment_with_type(node) do
111-
@path.push("fragment #{node.name}")
112-
@in_fragment_def = false
113-
call_on_enter_fragment_definition(node, parent)
114-
super
115-
@in_fragment_def = false
116-
call_on_leave_fragment_definition(node, parent)
117-
end
118-
end
119-
120111
def on_inline_fragment(node, parent)
121-
on_fragment_with_type(node) do
122-
@path.push("...#{node.type ? " on #{node.type.name}" : ""}")
123-
@skipping = @skip_stack.last || skip?(node)
124-
@skip_stack << @skipping
125-
126-
call_on_enter_inline_fragment(node, parent)
127-
super
128-
@skipping = @skip_stack.pop
129-
call_on_leave_inline_fragment(node, parent)
112+
check_timeout
113+
object_type = if node.type
114+
@types.type(node.type.name)
115+
else
116+
@object_types.last
130117
end
118+
@object_types.push(object_type)
119+
@path.push("...#{node.type ? " on #{node.type.name}" : ""}")
120+
@skipping = @skip_stack.last || skip?(node)
121+
@skip_stack << @skipping
122+
call_on_enter_inline_fragment(node, parent)
123+
super
124+
@skipping = @skip_stack.pop
125+
call_on_leave_inline_fragment(node, parent)
126+
@object_types.pop
127+
@path.pop
131128
end
132129

133130
def on_field(node, parent)
131+
check_timeout
134132
@response_path.push(node.alias || node.name)
135133
parent_type = @object_types.last
136134
# This could be nil if the previous field wasn't found:
@@ -158,6 +156,7 @@ def on_field(node, parent)
158156
end
159157

160158
def on_directive(node, parent)
159+
check_timeout
161160
directive_defn = @schema.directives[node.name]
162161
@directive_definitions.push(directive_defn)
163162
call_on_enter_directive(node, parent)
@@ -167,6 +166,7 @@ def on_directive(node, parent)
167166
end
168167

169168
def on_argument(node, parent)
169+
check_timeout
170170
argument_defn = if (arg = @argument_definitions.last)
171171
arg_type = arg.type.unwrap
172172
if arg_type.kind.input_object?
@@ -192,6 +192,7 @@ def on_argument(node, parent)
192192
end
193193

194194
def on_fragment_spread(node, parent)
195+
check_timeout
195196
@path.push("... #{node.name}")
196197
@skipping = @skip_stack.last || skip?(node)
197198
@skip_stack << @skipping
@@ -269,16 +270,10 @@ def skip?(ast_node)
269270
!dir.empty? && !GraphQL::Execution::DirectiveChecks.include?(dir, query)
270271
end
271272

272-
def on_fragment_with_type(node)
273-
object_type = if node.type
274-
@types.type(node.type.name)
275-
else
276-
@object_types.last
273+
def check_timeout
274+
if Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_second) > @timeout_time
275+
raise GraphQL::Analysis::TimeoutError
277276
end
278-
@object_types.push(object_type)
279-
yield(node)
280-
@object_types.pop
281-
@path.pop
282277
end
283278
end
284279
end

lib/graphql/execution/interpreter/runtime.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -473,7 +473,7 @@ def continue_value(value, field, is_non_null, ast_node, result_name, selection_r
473473
# When this comes from a list item, use the parent object:
474474
parent_type = selection_result.is_a?(GraphQLResultArray) ? selection_result.graphql_parent.graphql_result_type : selection_result.graphql_result_type
475475
# This block is called if `result_name` is not dead. (Maybe a previous invalid nil caused it be marked dead.)
476-
err = parent_type::InvalidNullError.new(parent_type, field, value, ast_node)
476+
err = parent_type::InvalidNullError.new(parent_type, field, ast_node)
477477
schema.type_error(err, context)
478478
end
479479
else

lib/graphql/invalid_null_error.rb

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,12 @@ class InvalidNullError < GraphQL::Error
99
# @return [GraphQL::Field] The field which failed to return a value
1010
attr_reader :field
1111

12-
# @return [nil, GraphQL::ExecutionError] The invalid value for this field
13-
attr_reader :value
14-
1512
# @return [GraphQL::Language::Nodes::Field] the field where the error occurred
1613
attr_reader :ast_node
1714

18-
def initialize(parent_type, field, value, ast_node)
15+
def initialize(parent_type, field, ast_node)
1916
@parent_type = parent_type
2017
@field = field
21-
@value = value
2218
@ast_node = ast_node
2319
super("Cannot return null for non-nullable field #{@parent_type.graphql_name}.#{@field.graphql_name}")
2420
end

lib/graphql/language/lexer.rb

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,21 @@ def initialize(graphql_str, filename: nil, max_tokens: nil)
1313
@pos = nil
1414
@max_tokens = max_tokens || Float::INFINITY
1515
@tokens_count = 0
16+
@finished = false
1617
end
1718

18-
def eos?
19-
@scanner.eos?
19+
def finished?
20+
@finished
2021
end
2122

2223
attr_reader :pos, :tokens_count
2324

2425
def advance
2526
@scanner.skip(IGNORE_REGEXP)
26-
return false if @scanner.eos?
27+
if @scanner.eos?
28+
@finished = true
29+
return false
30+
end
2731
@tokens_count += 1
2832
if @tokens_count > @max_tokens
2933
raise_parse_error("This query is too large to execute.")

lib/graphql/language/parser.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ def document
110110
# Only ignored characters is not a valid document
111111
raise GraphQL::ParseError.new("Unexpected end of document", nil, nil, @graphql_str)
112112
end
113-
while !@lexer.eos?
113+
while !@lexer.finished?
114114
defns << definition
115115
end
116116
Document.new(pos: 0, definitions: defns, filename: @filename, source: self)

lib/graphql/schema.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -821,13 +821,13 @@ def subscription_execution_strategy(new_subscription_execution_strategy = nil, d
821821

822822
attr_writer :validate_timeout
823823

824-
def validate_timeout(new_validate_timeout = nil)
825-
if new_validate_timeout
824+
def validate_timeout(new_validate_timeout = NOT_CONFIGURED)
825+
if !NOT_CONFIGURED.equal?(new_validate_timeout)
826826
@validate_timeout = new_validate_timeout
827827
elsif defined?(@validate_timeout)
828828
@validate_timeout
829829
else
830-
find_inherited_value(:validate_timeout)
830+
find_inherited_value(:validate_timeout) || 3
831831
end
832832
end
833833

lib/graphql/schema/resolver.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ class Resolver
2222
include Schema::Member::GraphQLTypeNames
2323
# Really we only need description & comment from here, but:
2424
extend Schema::Member::BaseDSLMethods
25-
extend Member::BaseDSLMethods::ConfigurationExtension
2625
extend GraphQL::Schema::Member::HasArguments
2726
extend GraphQL::Schema::Member::HasValidators
2827
include Schema::Member::HasPath
@@ -404,6 +403,11 @@ def extensions
404403
end
405404
end
406405

406+
def inherited(child_class)
407+
child_class.description(description)
408+
super
409+
end
410+
407411
private
408412

409413
attr_reader :own_extensions

lib/graphql/static_validation/rules/fields_will_merge.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,7 @@ def find_fields_and_fragments(selections, owner_type:, parents:, fields:, fragme
345345
fields << Field.new(node, definition, owner_type, parents)
346346
when GraphQL::Language::Nodes::InlineFragment
347347
fragment_type = node.type ? @types.type(node.type.name) : owner_type
348-
find_fields_and_fragments(node.selections, parents: [*parents, fragment_type], owner_type: owner_type, fields: fields, fragment_spreads: fragment_spreads) if fragment_type
348+
find_fields_and_fragments(node.selections, parents: [*parents, fragment_type], owner_type: fragment_type, fields: fields, fragment_spreads: fragment_spreads) if fragment_type
349349
when GraphQL::Language::Nodes::FragmentSpread
350350
fragment_spreads << FragmentSpread.new(node.name, parents)
351351
end

0 commit comments

Comments
 (0)