When used on object types, dynamic access checks can only be run after the parent field has already been resolved (otherwise there would be no object to pass into the block). Unfortunately, this opens the door for a number of side- channel attacks.
Consider for example the following simplified schema definition:
class QueryRoot < GraphApi::ObjectType
field :orders, [Order] do |field|
field.argument :first, :integer
field.resolve = ->(_, args, ctx) { Order.first(args[:first]) }
end
end
class Order < GraphApi::ObjectType
required_access(:read_orders) do |order, ctx|
order.owner == ctx[:app]
end
field :id
end
When the orders
field is resolved on the root, it needs to do two checks: one
static one for the presence of the read_orders
scope and one dynamic one using the block.
The static check it can do before execution, allowing it to abort that field
immediately before the resolve
is run. Nothing can be leaked in this
situation. Unfortunately the dynamic check is not as simple.
The dynamic check can only be run with the order object present, which means the
QueryRoot.orders
field must be resolved first. By combining this with the
first
argument, an attacker can access information they shouldn't. If
first: 3
successfully returns three orders, but first: 4
fails with a
permissions error, then an attacker knows the location and existence of an order
with some other owner. This specific example is not particularly concerning,
but there are more complicated variants with other arguments (e.g. sort order
and text search) which can leak sensitive information.
The solution to each of these cases is to bake the necessary check into the
resolve block of the field, often by e.g. adding a WHERE
clause to the SQL
query. This prevents the side-channel leakage by removing the inaccessible
objects from the field's return value in advance. In our example, that would
look something like:
field :orders, [Order] do |field|
field.argument :first, :integer
field.resolve = ->(_, args, ctx) do
Order.where(owner: ctx[:app]).first(args[:first])
end
end
If something does slip through and the dynamic access check fails, it will not
result in an "access denied" to the client: it will result in "internal error",
and will raise an exception with the message FIELD tried to return a value which failed the access check
. This means that the field in question was
resolved but then failed a dynamic access check after the fact. When this
occurs, it is a bug which must be fixed because there is the potential for
side-channel leaks.
Ok