Merge pull request #14216 from hmac/hmac-graphql-enum

Ruby: Restrict GraphQL remote flow sources
This commit is contained in:
Harry Maclean
2023-10-13 11:31:50 +01:00
committed by GitHub
11 changed files with 315 additions and 30 deletions

View File

@@ -0,0 +1,4 @@
---
category: minorAnalysis
---
* GraphQL enums are no longer considered remote flow sources.

View File

@@ -253,6 +253,46 @@ class GraphqlFieldDefinitionMethodCall extends GraphqlSchemaObjectClassMethodCal
/** Gets the name of this GraphQL field. */
string getFieldName() { result = this.getArgument(0).getConstantValue().getStringlikeValue() }
/**
* Gets the type of this field.
*/
GraphqlType getFieldType() { result = this.getArgument(1) }
/**
* Gets an argument call inside this field definition.
*/
GraphqlFieldArgumentDefinitionMethodCall getAnArgumentCall() { result = this.getArgumentCall(_) }
/**
* Gets the argument call for `name` inside this field definition.
*/
GraphqlFieldArgumentDefinitionMethodCall getArgumentCall(string name) {
result.getEnclosingCallable() = this.getBlock() and result.getArgumentName() = name
}
}
/**
* A call to `argument` in a GraphQL InputObject class.
*/
class GraphqlInputObjectArgumentDefinitionCall extends DataFlow::CallNode {
GraphqlInputObjectArgumentDefinitionCall() {
this =
graphQlSchema()
.getMember("InputObject")
.getADescendentModule()
.getAnOwnModuleSelf()
.getAMethodCall("argument")
}
/** Gets the name of the argument (i.e. the first argument to this `argument` method call) */
string getArgumentName() { result = this.getArgument(0).getConstantValue().getStringlikeValue() }
/** Gets the type of this argument */
GraphqlType getArgumentType() { result = this.getArgument(1).asExpr().getExpr() }
/** Gets the class representing the receiver of this method. */
ClassDeclaration getReceiverClass() { result = this.asExpr().getExpr().getEnclosingModule() }
}
/**
@@ -289,6 +329,64 @@ private class GraphqlFieldArgumentDefinitionMethodCall extends GraphqlSchemaObje
/** Gets the name of the argument (i.e. the first argument to this `argument` method call) */
string getArgumentName() { result = this.getArgument(0).getConstantValue().getStringlikeValue() }
/** Gets the type of this argument */
GraphqlType getArgumentType() { result = this.getArgument(1) }
/**
* Gets the element type of this argument, if it is an array.
* For example if the argument type is `[String]`, this predicate yields `String`.
*/
GraphqlType getArgumentElementType() {
result =
any(ArrayLiteral lit | lit = this.getArgument(1) and lit.getNumberOfElements() = 1)
.getElement(0)
}
}
private class GraphqlType extends ConstantAccess {
/**
* Gets the module corresponding to this type, if it exists.
*/
Module getModule() { result.getAnImmediateReference() = this }
/**
* Gets the type of a field/argument of this type, if it is an object type.
*/
GraphqlType getAFieldOrArgument() { result = this.getFieldOrArgument(_) }
/**
* Gets the type of the `name` field/argument of this type, if it exists.
*/
GraphqlType getFieldOrArgument(string name) {
result =
any(GraphqlFieldDefinitionMethodCall field |
field.getFieldName() = name and
this.getModule().getADeclaration() = field.getReceiverClass()
).getFieldType() or
result =
any(GraphqlInputObjectArgumentDefinitionCall arg |
arg.getArgumentName() = name and this.getModule().getADeclaration() = arg.getReceiverClass()
).getArgumentType()
}
/**
* Holds if this type is an enum.
*/
predicate isEnum() {
API::getTopLevelMember("GraphQL")
.getMember("Schema")
.getMember("Enum")
.getADescendentModule()
.getAnImmediateReference()
.asExpr()
.getExpr() = this
}
/**
* Holds if this type is scalar - i.e. it is neither an object or an enum.
*/
predicate isScalar() { not exists(this.getAFieldOrArgument()) and not this.isEnum() }
}
/**
@@ -350,29 +448,26 @@ class GraphqlFieldResolutionMethod extends Method, Http::Server::RequestHandler:
/** Gets the method call which is the definition of the field corresponding to this resolver method. */
GraphqlFieldDefinitionMethodCall getDefinition() {
result
.getKeywordArgument("resolver_method")
.getConstantValue()
.isStringlikeValue(this.getName())
or
not exists(result.getKeywordArgument("resolver_method").(SymbolLiteral)) and
result.getFieldName() = this.getName()
result.getEnclosingModule() = this.getEnclosingModule() and
(
result
.getKeywordArgument("resolver_method")
.getConstantValue()
.isStringlikeValue(this.getName())
or
not exists(result.getKeywordArgument("resolver_method").(SymbolLiteral)) and
result.getFieldName() = this.getName()
)
}
// check for a named argument the same name as a defined argument for this field
override Parameter getARoutedParameter() {
result = this.getAParameter() and
exists(GraphqlFieldArgumentDefinitionMethodCall argDefn |
argDefn.getEnclosingCallable() = this.getDefinition().getBlock() and
(
result.(KeywordParameter).hasName(argDefn.getArgumentName())
or
// TODO this will cause false positives because now *anything* in the **args
// param will be flagged as RoutedParameter/RemoteFlowSource, but really
// only the hash keys corresponding to the defined arguments are user input
// others could be things defined in the `:extras` keyword argument to the `argument`
result instanceof HashSplatParameter // often you see `def field(**args)`
)
argDefn = this.getDefinition().getAnArgumentCall() and
[argDefn.getArgumentType(), argDefn.getArgumentElementType()].isScalar()
|
result.(KeywordParameter).hasName(argDefn.getArgumentName())
)
}
@@ -383,3 +478,80 @@ class GraphqlFieldResolutionMethod extends Method, Http::Server::RequestHandler:
/** Gets the class containing this method. */
GraphqlSchemaObjectClass getGraphqlClass() { result = schemaObjectClass }
}
private DataFlow::CallNode hashParameterAccess(
GraphqlFieldResolutionMethod method, HashSplatParameter param, GraphqlType type
) {
exists(
DataFlow::LocalSourceNode paramNode, GraphqlFieldArgumentDefinitionMethodCall def, string key
|
param = method.getAParameter() and
paramNode.(DataFlow::ParameterNode).getParameter() = param and
def = method.getDefinition().getAnArgumentCall() and
(
// Direct access to the params hash
[def.getArgumentType(), def.getArgumentElementType()] = type and
def.getArgumentName() = key and
paramNode.flowsTo(hashAccess(result, key))
or
// Nested access
exists(GraphqlType type2 |
hashParameterAccess(method, param, type2)
.(DataFlow::LocalSourceNode)
.flowsTo(hashAccess(result, key)) and
type2.getFieldOrArgument(key) = type
)
)
)
}
private DataFlow::Node parameterAccess(
GraphqlFieldResolutionMethod method, DataFlow::LocalSourceNode param, GraphqlType type
) {
param = getAGraphqlParameter(method, type) and
result = param
or
exists(string key, GraphqlType type2 |
param = parameterAccess(method, _, type2) and
param.flowsTo(hashAccess(result, key)) and
type2.getFieldOrArgument(key) = type
)
}
private DataFlow::ParameterNode getAGraphqlParameter(
GraphqlFieldResolutionMethod method, GraphqlType type
) {
result.getCallable() = method and
(
result.getParameter() instanceof KeywordParameter and
exists(GraphqlFieldArgumentDefinitionMethodCall c |
c = method.getDefinition().getArgumentCall(result.getName())
|
type = [c.getArgumentType(), c.getArgumentElementType()]
)
or
result.getParameter() instanceof SimpleParameter and
type = method.getDefinition().getFieldType()
)
}
/**
* Gets the receiver of the hash access `access` with key `key`.
* For example, in `h["foo"]` the receiver is `h`, the key is "foo"
* and `access` is the dataflow node for the whole expression.
*/
private DataFlow::Node hashAccess(DataFlow::CallNode access, string key) {
access.asExpr() instanceof ExprNodes::ElementReferenceCfgNode and
access.getArgument(0).getConstantValue().isStringlikeValue(key) and
access.getReceiver() = result
}
private class GraphqlParameterAccess extends RemoteFlowSource::Range {
GraphqlParameterAccess() {
exists(GraphqlType type | type.isScalar() |
this = hashParameterAccess(_, _, type) or this = parameterAccess(_, _, type)
)
}
override string getSourceType() { result = "GraphQL" }
}

View File

@@ -1,14 +1,23 @@
graphqlSchemaObjectClass
| app/graphql/types/base_object.rb:2:3:4:5 | BaseObject |
| app/graphql/types/mutation_type.rb:2:3:4:5 | MutationType |
| app/graphql/types/query_type.rb:2:3:45:5 | QueryType |
| app/graphql/types/post.rb:1:1:6:5 | Post |
| app/graphql/types/query_type.rb:2:3:85:5 | QueryType |
graphqlSchemaObjectFieldDefinition
| app/graphql/types/mutation_type.rb:2:3:4:5 | MutationType | app/graphql/types/mutation_type.rb:3:5:3:44 | call to field |
| app/graphql/types/query_type.rb:2:3:45:5 | QueryType | app/graphql/types/query_type.rb:3:5:5:40 | call to field |
| app/graphql/types/query_type.rb:2:3:45:5 | QueryType | app/graphql/types/query_type.rb:7:5:9:7 | call to field |
| app/graphql/types/query_type.rb:2:3:45:5 | QueryType | app/graphql/types/query_type.rb:15:5:17:7 | call to field |
| app/graphql/types/query_type.rb:2:3:45:5 | QueryType | app/graphql/types/query_type.rb:24:5:26:7 | call to field |
| app/graphql/types/query_type.rb:2:3:45:5 | QueryType | app/graphql/types/query_type.rb:32:5:35:7 | call to field |
| app/graphql/types/post.rb:1:1:6:5 | Post | app/graphql/types/post.rb:2:5:2:24 | call to field |
| app/graphql/types/post.rb:1:1:6:5 | Post | app/graphql/types/post.rb:3:5:3:36 | call to field |
| app/graphql/types/post.rb:1:1:6:5 | Post | app/graphql/types/post.rb:4:5:4:60 | call to field |
| app/graphql/types/post.rb:1:1:6:5 | Post | app/graphql/types/post.rb:5:5:5:51 | call to field |
| app/graphql/types/query_type.rb:2:3:85:5 | QueryType | app/graphql/types/query_type.rb:3:5:5:40 | call to field |
| app/graphql/types/query_type.rb:2:3:85:5 | QueryType | app/graphql/types/query_type.rb:7:5:9:7 | call to field |
| app/graphql/types/query_type.rb:2:3:85:5 | QueryType | app/graphql/types/query_type.rb:15:5:17:7 | call to field |
| app/graphql/types/query_type.rb:2:3:85:5 | QueryType | app/graphql/types/query_type.rb:24:5:26:7 | call to field |
| app/graphql/types/query_type.rb:2:3:85:5 | QueryType | app/graphql/types/query_type.rb:32:5:35:7 | call to field |
| app/graphql/types/query_type.rb:2:3:85:5 | QueryType | app/graphql/types/query_type.rb:46:5:49:7 | call to field |
| app/graphql/types/query_type.rb:2:3:85:5 | QueryType | app/graphql/types/query_type.rb:55:5:57:7 | call to field |
| app/graphql/types/query_type.rb:2:3:85:5 | QueryType | app/graphql/types/query_type.rb:65:5:67:7 | call to field |
| app/graphql/types/query_type.rb:2:3:85:5 | QueryType | app/graphql/types/query_type.rb:72:5:76:7 | call to field |
graphqlResolveMethod
| app/graphql/mutations/dummy.rb:9:5:12:7 | resolve |
| app/graphql/resolvers/dummy_resolver.rb:10:5:13:7 | resolve |
@@ -23,24 +32,56 @@ graphqlLoadMethodRoutedParameter
| app/graphql/resolvers/dummy_resolver.rb:6:5:8:7 | load_something | app/graphql/resolvers/dummy_resolver.rb:6:24:6:25 | id |
graphqlFieldDefinitionMethodCall
| app/graphql/types/mutation_type.rb:3:5:3:44 | call to field |
| app/graphql/types/post.rb:2:5:2:24 | call to field |
| app/graphql/types/post.rb:3:5:3:36 | call to field |
| app/graphql/types/post.rb:4:5:4:60 | call to field |
| app/graphql/types/post.rb:5:5:5:51 | call to field |
| app/graphql/types/query_type.rb:3:5:5:40 | call to field |
| app/graphql/types/query_type.rb:7:5:9:7 | call to field |
| app/graphql/types/query_type.rb:15:5:17:7 | call to field |
| app/graphql/types/query_type.rb:24:5:26:7 | call to field |
| app/graphql/types/query_type.rb:32:5:35:7 | call to field |
| app/graphql/types/query_type.rb:46:5:49:7 | call to field |
| app/graphql/types/query_type.rb:55:5:57:7 | call to field |
| app/graphql/types/query_type.rb:65:5:67:7 | call to field |
| app/graphql/types/query_type.rb:72:5:76:7 | call to field |
graphqlFieldResolutionMethod
| app/graphql/types/query_type.rb:10:5:13:7 | with_arg |
| app/graphql/types/query_type.rb:18:5:22:7 | custom_method |
| app/graphql/types/query_type.rb:27:5:30:7 | with_splat |
| app/graphql/types/query_type.rb:36:5:40:7 | with_splat_and_named_arg |
| app/graphql/types/query_type.rb:50:5:53:7 | with_enum |
| app/graphql/types/query_type.rb:58:5:63:7 | with_nested_enum |
| app/graphql/types/query_type.rb:68:5:70:7 | with_array |
| app/graphql/types/query_type.rb:77:5:84:7 | with_named_params |
graphqlFieldResolutionRoutedParameter
| app/graphql/types/query_type.rb:10:5:13:7 | with_arg | app/graphql/types/query_type.rb:10:18:10:23 | number |
| app/graphql/types/query_type.rb:18:5:22:7 | custom_method | app/graphql/types/query_type.rb:18:23:18:33 | blah_number |
| app/graphql/types/query_type.rb:27:5:30:7 | with_splat | app/graphql/types/query_type.rb:27:20:27:25 | **args |
| app/graphql/types/query_type.rb:36:5:40:7 | with_splat_and_named_arg | app/graphql/types/query_type.rb:36:34:36:37 | arg1 |
| app/graphql/types/query_type.rb:36:5:40:7 | with_splat_and_named_arg | app/graphql/types/query_type.rb:36:41:36:46 | **rest |
| app/graphql/types/query_type.rb:68:5:70:7 | with_array | app/graphql/types/query_type.rb:68:20:68:23 | list |
| app/graphql/types/query_type.rb:77:5:84:7 | with_named_params | app/graphql/types/query_type.rb:77:27:77:30 | arg1 |
graphqlFieldResolutionDefinition
| app/graphql/types/query_type.rb:10:5:13:7 | with_arg | app/graphql/types/query_type.rb:7:5:9:7 | call to field |
| app/graphql/types/query_type.rb:18:5:22:7 | custom_method | app/graphql/types/query_type.rb:15:5:17:7 | call to field |
| app/graphql/types/query_type.rb:27:5:30:7 | with_splat | app/graphql/types/query_type.rb:24:5:26:7 | call to field |
| app/graphql/types/query_type.rb:36:5:40:7 | with_splat_and_named_arg | app/graphql/types/query_type.rb:32:5:35:7 | call to field |
| app/graphql/types/query_type.rb:50:5:53:7 | with_enum | app/graphql/types/query_type.rb:46:5:49:7 | call to field |
| app/graphql/types/query_type.rb:58:5:63:7 | with_nested_enum | app/graphql/types/query_type.rb:55:5:57:7 | call to field |
| app/graphql/types/query_type.rb:68:5:70:7 | with_array | app/graphql/types/query_type.rb:65:5:67:7 | call to field |
| app/graphql/types/query_type.rb:77:5:84:7 | with_named_params | app/graphql/types/query_type.rb:72:5:76:7 | call to field |
graphqlRemoteFlowSources
| app/graphql/mutations/dummy.rb:5:24:5:25 | id |
| app/graphql/mutations/dummy.rb:9:17:9:25 | something |
| app/graphql/resolvers/dummy_resolver.rb:6:24:6:25 | id |
| app/graphql/resolvers/dummy_resolver.rb:10:17:10:25 | something |
| app/graphql/types/query_type.rb:10:18:10:23 | number |
| app/graphql/types/query_type.rb:18:23:18:33 | blah_number |
| app/graphql/types/query_type.rb:28:22:28:37 | ...[...] |
| app/graphql/types/query_type.rb:29:7:29:22 | ...[...] |
| app/graphql/types/query_type.rb:36:34:36:37 | arg1 |
| app/graphql/types/query_type.rb:38:22:38:32 | ...[...] |
| app/graphql/types/query_type.rb:52:22:52:32 | ...[...] |
| app/graphql/types/query_type.rb:60:22:60:41 | ...[...] |
| app/graphql/types/query_type.rb:68:20:68:23 | list |
| app/graphql/types/query_type.rb:77:27:77:30 | arg1 |
| app/graphql/types/query_type.rb:80:22:80:33 | ...[...] |

View File

@@ -1,5 +1,6 @@
private import codeql.ruby.frameworks.GraphQL
private import codeql.ruby.AST
private import codeql.ruby.dataflow.RemoteFlowSources
query predicate graphqlSchemaObjectClass(GraphqlSchemaObjectClass cls) { any() }
@@ -34,3 +35,5 @@ query predicate graphqlFieldResolutionDefinition(
) {
meth.getDefinition() = def
}
query predicate graphqlRemoteFlowSources(RemoteFlowSource src) { any() }

View File

@@ -0,0 +1,2 @@
class Types::BaseEnum < GraphQL::Schema::Enum
end

View File

@@ -0,0 +1,6 @@
module Types
class Direction < Types::BaseEnum
value "asc", "Ascending order", value: "asc"
value "desc", "Descending order", value: "desc"
end
end

View File

@@ -0,0 +1,8 @@
module Types
class MediaCategory < Types::BaseEnum
value "AUDIO", "An audio file, such as music or spoken word"
value "IMAGE", "A still image, such as a photo or graphic"
value "TEXT", "Written words"
value "VIDEO", "Motion picture, may have audio"
end
end

View File

@@ -0,0 +1,8 @@
class Types::Post < GraphQL::Schema::Object
field :title, String
field :body, String, null: false
field :media_category, Types::MediaCategory, null: false
field :direction, Types::Direction, null: false
end
end

View File

@@ -0,0 +1,5 @@
module Types
class PostOrder < Types::BaseInputObject
argument :direction, Types::Direction, "The ordering direction", required: true
end
end

View File

@@ -42,5 +42,45 @@ module Types
def foo(arg)
system("echo #{arg}")
end
field :with_enum, String, null: false, description: "A field with an enum argument" do
argument :enum, Types::MediaCategory, "An enum", required: true
argument :arg2, String, "Another arg", required: true
end
def with_enum(**args)
system("echo #{args[:enum]}")
system("echo #{args[:arg2]}")
end
field :with_nested_enum, String, null: false, description: "A field with a nested enum argument" do
argument :inner, Types::Post, "Post", required: true
end
def with_nested_enum(**args)
system("echo #{args[:inner]}")
system("echo #{args[:inner][:title]}")
system("echo #{args[:inner][:media_category]}")
system("echo #{args[:inner][:direction]}")
end
field :with_array, String do
argument :list, [String], "Names"
end
def with_array(list:)
system("echo #{list[0]}")
end
field :with_named_params, String do
argument :arg1, String, "Arg 1"
argument :arg2, Types::Post, "Arg 2"
argument :arg3, Types::MediaCategory, "Arg 3"
end
def with_named_params(arg1:, arg2:, **args)
system("echo #{arg1}")
system("echo #{arg2}")
system("echo #{arg2[:title]}")
system("echo #{arg2[:media_category]}")
system("echo #{args[:arg3]}")
system("echo #{args[:not_an_arg]}")
end
end
end

View File

@@ -17,8 +17,6 @@ edges
| CommandInjection.rb:54:13:54:24 | ...[...] | CommandInjection.rb:54:7:54:9 | cmd |
| CommandInjection.rb:73:18:73:23 | number | CommandInjection.rb:74:14:74:29 | "echo #{...}" |
| CommandInjection.rb:81:23:81:33 | blah_number | CommandInjection.rb:82:14:82:34 | "echo #{...}" |
| CommandInjection.rb:90:20:90:25 | **args | CommandInjection.rb:91:22:91:25 | args |
| CommandInjection.rb:91:22:91:25 | args | CommandInjection.rb:91:22:91:37 | ...[...] |
| CommandInjection.rb:91:22:91:37 | ...[...] | CommandInjection.rb:91:14:91:39 | "echo #{...}" |
| CommandInjection.rb:103:9:103:12 | file | CommandInjection.rb:104:16:104:28 | "cat #{...}" |
| CommandInjection.rb:103:16:103:21 | call to params | CommandInjection.rb:103:16:103:28 | ...[...] |
@@ -47,9 +45,7 @@ nodes
| CommandInjection.rb:74:14:74:29 | "echo #{...}" | semmle.label | "echo #{...}" |
| CommandInjection.rb:81:23:81:33 | blah_number | semmle.label | blah_number |
| CommandInjection.rb:82:14:82:34 | "echo #{...}" | semmle.label | "echo #{...}" |
| CommandInjection.rb:90:20:90:25 | **args | semmle.label | **args |
| CommandInjection.rb:91:14:91:39 | "echo #{...}" | semmle.label | "echo #{...}" |
| CommandInjection.rb:91:22:91:25 | args | semmle.label | args |
| CommandInjection.rb:91:22:91:37 | ...[...] | semmle.label | ...[...] |
| CommandInjection.rb:103:9:103:12 | file | semmle.label | file |
| CommandInjection.rb:103:16:103:21 | call to params | semmle.label | call to params |
@@ -69,5 +65,5 @@ subpaths
| CommandInjection.rb:59:14:59:16 | cmd | CommandInjection.rb:54:13:54:18 | call to params | CommandInjection.rb:59:14:59:16 | cmd | This command depends on a $@. | CommandInjection.rb:54:13:54:18 | call to params | user-provided value |
| CommandInjection.rb:74:14:74:29 | "echo #{...}" | CommandInjection.rb:73:18:73:23 | number | CommandInjection.rb:74:14:74:29 | "echo #{...}" | This command depends on a $@. | CommandInjection.rb:73:18:73:23 | number | user-provided value |
| CommandInjection.rb:82:14:82:34 | "echo #{...}" | CommandInjection.rb:81:23:81:33 | blah_number | CommandInjection.rb:82:14:82:34 | "echo #{...}" | This command depends on a $@. | CommandInjection.rb:81:23:81:33 | blah_number | user-provided value |
| CommandInjection.rb:91:14:91:39 | "echo #{...}" | CommandInjection.rb:90:20:90:25 | **args | CommandInjection.rb:91:14:91:39 | "echo #{...}" | This command depends on a $@. | CommandInjection.rb:90:20:90:25 | **args | user-provided value |
| CommandInjection.rb:91:14:91:39 | "echo #{...}" | CommandInjection.rb:91:22:91:37 | ...[...] | CommandInjection.rb:91:14:91:39 | "echo #{...}" | This command depends on a $@. | CommandInjection.rb:91:22:91:37 | ...[...] | user-provided value |
| CommandInjection.rb:104:16:104:28 | "cat #{...}" | CommandInjection.rb:103:16:103:21 | call to params | CommandInjection.rb:104:16:104:28 | "cat #{...}" | This command depends on a $@. | CommandInjection.rb:103:16:103:21 | call to params | user-provided value |