Skip to content

Instantly share code, notes, and snippets.

@eapache
Created June 27, 2018 20:41
Show Gist options
  • Save eapache/63882c31f80a36f0ac4be78e0102fd60 to your computer and use it in GitHub Desktop.
Save eapache/63882c31f80a36f0ac4be78e0102fd60 to your computer and use it in GitHub Desktop.
GraphQL-Ruby Permissions Side-Channel

Major Caveat: Side-Channel Attacks

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.

@yra40
Copy link

yra40 commented Oct 20, 2021

Ok

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment