Class: Scryer::Rules::GraphqlMissingQueryLimitsRule

Inherits:
Scryer::Rule
  • Object
show all
Defined in:
lib/scryer/rules/graphql_missing_query_limits_rule.rb

Overview

Flags a class X < GraphQL::Schema (or a class whose superclass constant path text is exactly GraphQL::Schema) whose body calls neither max_depth nor max_complexity anywhere — without at least one of those, a client can send an arbitrarily deep/expensive query and the server will try to resolve all of it, a common denial-of-service vector for GraphQL APIs. Both missing is flagged as a single finding per class (not one per missing directive), the same "did the class do the safe thing at all" pattern as ActiveStorageMissingContentTypeValidationRule/IdorRule.

Known precision limits, both around indirection this rule can't (or doesn't try to) resolve, same disclosed-gap spirit as IdorRule:

- A schema that inherits from a shared custom base class (e.g.
`class MySchema < BaseSchema`, where `BaseSchema` is the one that
actually extends `GraphQL::Schema` and calls `max_depth`/
`max_complexity`) is never even examined by this rule — the
superclass-name check only matches a literal `GraphQL::Schema`
superclass, so `MySchema` here isn't checked at all (a
false-negative blind spot, not a false positive: verified via
`Scryer::Scanner` that such a subclass produces no finding either
way).
- Conversely, a class that DOES extend `GraphQL::Schema` directly
but gets its limits from an `include`d module (e.g. `include
QueryLimits`, where the module sets `max_depth`/`max_complexity`
via its own `included do ... end` block in a different file) WILL
be flagged as missing them, even though it isn't — confirmed via
`Scryer::Scanner` against exactly this fixture. Resolving what an
`include`d module does requires following it to its own
definition, potentially in another file entirely — real
cross-file resolution, not a same-file AST tweak — so rather than
risk a broad/wrong exemption (e.g. "any class that calls
`include` at all is exempt" would silence the rule for classes
that include something unrelated and still have no real limit),
this gap is left as-is and disclosed here instead. Treat a
finding on a schema using a shared limits module as worth a
second look, not a confirmed bug.

Constant Summary collapse

LIMIT_METHODS =
%w[max_depth max_complexity].freeze

Instance Attribute Summary

Attributes inherited from Scryer::Rule

#file, #sexp, #source

Instance Method Summary collapse

Methods inherited from Scryer::Rule

inherited, #initialize

Constructor Details

This class inherits a constructor from Scryer::Rule

Instance Method Details

#scanObject



51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
# File 'lib/scryer/rules/graphql_missing_query_limits_rule.rb', line 51

def scan
  findings = []

  Ast.each_node(sexp) do |node|
    next unless Ast.tagged?(node, :class)

    superclass = superclass_name(node[2])
    next unless superclass == "GraphQL::Schema"

    body = node[3]
    next if each_call_names(body).any? { |name| LIMIT_METHODS.include?(name) }

    class_name = Ast.ident_text(node[1].is_a?(Array) ? node[1][1] : nil)
    line = Ast.line_of(node)
    findings << finding(
      line: line,
      message: "`#{class_name}` extends `GraphQL::Schema` but calls neither `max_depth` nor " \
                "`max_complexity` — without either limit, a client can send an arbitrarily " \
                "deep or expensive query and the server will attempt to resolve all of it, a " \
                "common denial-of-service vector for GraphQL APIs.",
      suggested_fix: "Add at least one limit to the schema, e.g. `max_depth 15` and/or " \
                      "`max_complexity 300` (tune both to what this API's real queries need)."
    )
  end

  findings
end