Skip to content

Commit 20f1a74

Browse files
committed
Ruby: Restrict GraphQL remote flow sources
Previously we considered any splat parameter in a graphql resolver to be a remote flow source. Now we limit that to reads of the parameter which yield scalar types (e.g. String), as defined by the GraphQL schema. This should reduce GraphQL false positives.
1 parent b291ee3 commit 20f1a74

File tree

5 files changed

+116
-10
lines changed

5 files changed

+116
-10
lines changed

ruby/ql/lib/codeql/ruby/frameworks/GraphQL.qll

Lines changed: 81 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,12 @@ class GraphqlFieldDefinitionMethodCall extends GraphqlSchemaObjectClassMethodCal
253253

254254
/** Gets the name of this GraphQL field. */
255255
string getFieldName() { result = this.getArgument(0).getConstantValue().getStringlikeValue() }
256+
257+
GraphqlType getFieldType() { result = this.getArgument(1) }
258+
259+
GraphqlFieldArgumentDefinitionMethodCall getArgumentCall() {
260+
result.getEnclosingCallable() = this.getBlock()
261+
}
256262
}
257263

258264
/**
@@ -289,6 +295,38 @@ private class GraphqlFieldArgumentDefinitionMethodCall extends GraphqlSchemaObje
289295

290296
/** Gets the name of the argument (i.e. the first argument to this `argument` method call) */
291297
string getArgumentName() { result = this.getArgument(0).getConstantValue().getStringlikeValue() }
298+
299+
/** Gets the type of this argument */
300+
GraphqlType getArgumentType() { result = this.getArgument(1) }
301+
}
302+
303+
private DataFlow::LocalSourceNode graphQlEnum() {
304+
result =
305+
API::getTopLevelMember("GraphQL")
306+
.getMember("Schema")
307+
.getMember("Enum")
308+
.getADescendentModule()
309+
.getAnImmediateReference()
310+
}
311+
312+
private class GraphqlType extends ConstantAccess {
313+
Module getModule() { result.getAnImmediateReference() = this }
314+
315+
GraphqlType getAField() { result = this.getField(_) }
316+
317+
GraphqlType getField(string name) {
318+
result =
319+
any(GraphqlFieldDefinitionMethodCall field |
320+
field.getFieldName() = name and
321+
this.getModule().getADeclaration() = field.getReceiverClass()
322+
).getFieldType()
323+
}
324+
325+
predicate isEnum() { graphQlEnum().asExpr().getExpr() = this }
326+
327+
predicate isUserControlled() { this.getName() = ["String", "ID", "JSON"] }
328+
329+
predicate isScalar() { not exists(this.getAField()) and not this.isEnum() }
292330
}
293331

294332
/**
@@ -363,16 +401,9 @@ class GraphqlFieldResolutionMethod extends Method, Http::Server::RequestHandler:
363401
override Parameter getARoutedParameter() {
364402
result = this.getAParameter() and
365403
exists(GraphqlFieldArgumentDefinitionMethodCall argDefn |
366-
argDefn.getEnclosingCallable() = this.getDefinition().getBlock() and
367-
(
368-
result.(KeywordParameter).hasName(argDefn.getArgumentName())
369-
or
370-
// TODO this will cause false positives because now *anything* in the **args
371-
// param will be flagged as RoutedParameter/RemoteFlowSource, but really
372-
// only the hash keys corresponding to the defined arguments are user input
373-
// others could be things defined in the `:extras` keyword argument to the `argument`
374-
result instanceof HashSplatParameter // often you see `def field(**args)`
375-
)
404+
argDefn = this.getDefinition().getArgumentCall()
405+
|
406+
result.(KeywordParameter).hasName(argDefn.getArgumentName())
376407
)
377408
}
378409

@@ -383,3 +414,43 @@ class GraphqlFieldResolutionMethod extends Method, Http::Server::RequestHandler:
383414
/** Gets the class containing this method. */
384415
GraphqlSchemaObjectClass getGraphqlClass() { result = schemaObjectClass }
385416
}
417+
418+
private DataFlow::CallNode hashAccess(DataFlow::Node recv, string key) {
419+
result.asExpr() instanceof ExprNodes::ElementReferenceCfgNode and
420+
result.getArgument(0).getConstantValue().isStringlikeValue(key) and
421+
result.getReceiver() = recv
422+
}
423+
424+
private DataFlow::CallNode parameterAccess(
425+
GraphqlFieldResolutionMethod method, GraphqlFieldArgumentDefinitionMethodCall def,
426+
HashSplatParameter param, string key, GraphqlType type
427+
) {
428+
param = method.getARoutedParameter() and
429+
def = method.getDefinition().getArgumentCall() and
430+
(
431+
// Direct access to the params hash
432+
def.getArgumentType() = type and
433+
def.getArgumentName() = key and
434+
exists(DataFlow::Node paramRead |
435+
paramRead.asExpr().getExpr() = param.getVariable().getAnAccess().(VariableReadAccess) and
436+
result = hashAccess(paramRead, key)
437+
)
438+
or
439+
// Nested access
440+
exists(GraphqlType type2 |
441+
parameterAccess(method, _, param, _, type2)
442+
.(DataFlow::LocalSourceNode)
443+
.flowsTo(result.getReceiver()) and
444+
result = hashAccess(_, key) and
445+
type2.getField(key) = type
446+
)
447+
)
448+
}
449+
450+
private class GraphqlParameterAccess extends RemoteFlowSource::Range {
451+
GraphqlParameterAccess() {
452+
exists(GraphqlType type | this = parameterAccess(_, _, _, _, type) and type.isScalar())
453+
}
454+
455+
override string getSourceType() { result = "GraphQL" }
456+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
class Types::BaseEnum < GraphQL::Schema::Enum
2+
end
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
module Types
2+
class MediaCategory < Types::BaseEnum
3+
value "AUDIO", "An audio file, such as music or spoken word"
4+
value "IMAGE", "A still image, such as a photo or graphic"
5+
value "TEXT", "Written words"
6+
value "VIDEO", "Motion picture, may have audio"
7+
end
8+
end
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
class Types::Post < GraphQL::Schema::Object
2+
field :title, String
3+
field :body, String, null: false
4+
field :media_category, Types::MediaCategory, null: false
5+
end
6+
end
7+

ruby/ql/test/library-tests/frameworks/graphql/app/graphql/types/query_type.rb

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,5 +42,23 @@ def with_splat_and_named_arg(arg1:, **rest)
4242
def foo(arg)
4343
system("echo #{arg}")
4444
end
45+
46+
field :with_enum, String, null: false, description: "A field with an enum argument" do
47+
argument :enum, Types::MediaCategory, "An enum", required: true
48+
argument :arg2, String, "Another arg", required: true
49+
end
50+
def with_enum(**args)
51+
system("echo #{args[:enum]}")
52+
system("echo #{args[:arg2]}")
53+
end
54+
55+
field :with_nested_enum, String, null: false, description: "A field with a nested enum argument" do
56+
argument :inner, Types::Post, "Post", required: true
57+
end
58+
def with_nested_enum(**args)
59+
system("echo #{args[:inner]}")
60+
system("echo #{args[:inner][:title]}")
61+
system("echo #{args[:inner][:media_category]}")
62+
end
4563
end
4664
end

0 commit comments

Comments
 (0)