Repository: saleor/saleor

Issue:
Sensitive data exposed via Graphql
By default Saleor exposes sensitive business information like:

ProductVariant {
  quantity: Int!
  quantityAllocated: Int!
  costPrice: Money
  stockQuantity: Int!
  margin: Int
  quantityOrdered: Int
  revenue(...): TaxedMoney
}

I hope nobody is running Saleor out there with theses fields exposed to public.



Discussion:
Thanks for reporting. Some of these fields are used by the management dashboard and should be available only to authenticated users with proper permissions. I see that some of them are missing those permissions checks. They will remain in the public schema, but shouldn't be accessible by unauthorized users.
@maarcingebala I think it would be important to inform current shop owners about this. 
Also looks like it is a design flaw since there is no field "inStock" for variants. 
At the moment only way the storefront can tell if something is in stock is by reading the public stock property. 

This is quite a serious issue for anyone running Saleor in production.

Resolving patch:
diff --git a/saleor/graphql/product/types.py b/saleor/graphql/product/types.py
--- a/saleor/graphql/product/types.py
+++ b/saleor/graphql/product/types.py
@@ -6,6 +6,7 @@
 from django.db.models import Prefetch
 from graphene import relay
 from graphql.error import GraphQLError
+from graphql_jwt.decorators import permission_required
 
 from ...product import models
 from ...product.templatetags.product_images import (
@@ -15,7 +16,6 @@
 from ...product.utils.costs import (
     get_margin_for_variant, get_product_costs_data)
 from ..core.connection import CountableDjangoObjectType
-from ..core.decorators import permission_required
 from ..core.enums import ReportingPeriod, TaxRateType
 from ..core.fields import PrefetchingConnectionField
 from ..core.types import Money, MoneyRange, TaxedMoney, TaxedMoneyRange
@@ -140,7 +140,7 @@ class ProductVariant(CountableDjangoObjectType):
         description=dedent("""Override the base price of a product if necessary.
         A value of `null` indicates that the default product
         price is used."""))
-    price = graphene.Field(Money, description="Price of the product variant.")
+    price = graphene.Field(Money, description='Price of the product variant.')
     attributes = graphene.List(
         graphene.NonNull(SelectedAttribute), required=True,
         description='List of attributes assigned to this variant.')
@@ -176,6 +176,7 @@ def resolve_attributes(self, info):
         attributes_qs = self.product.product_type.variant_attributes.all()
         return resolve_attribute_list(self.attributes, attributes_qs)
 
+    @permission_required('product.manage_products')
     def resolve_margin(self, info):
         return get_margin_for_variant(self)
 
@@ -188,11 +189,13 @@ def resolve_price(self, info):
     def resolve_price_override(self, info):
         return self.price_override
 
+    @permission_required(['order.manage_orders', 'product.manage_products'])
     def resolve_quantity_ordered(self, info):
         # This field is added through annotation when using the
         # `resolve_report_product_sales` resolver.
         return getattr(self, 'quantity_ordered', None)
 
+    @permission_required(['order.manage_orders', 'product.manage_products'])
     def resolve_revenue(self, info, period):
         start_date = reporting_period_to_date(period)
         return calculate_revenue_for_variant(self, start_date)


Test patch:
diff --git a/tests/api/test_product.py b/tests/api/test_product.py
--- a/tests/api/test_product.py
+++ b/tests/api/test_product.py
@@ -1598,7 +1598,7 @@ def test_stock_availability_filter(user_api_client, product):
     assert content['data']['products']['totalCount'] == 0
 
 
-def test_product_sales(
+def test_report_product_sales(
         staff_api_client, order_with_lines, permission_manage_products,
         permission_manage_orders):
     query = """
@@ -1637,3 +1637,64 @@ def test_product_sales(
     assert (
         node_b['revenue']['gross']['amount'] ==
         line_b.quantity * line_b.unit_price_gross.amount)
+
+
+def test_variant_revenue_permissions(
+        staff_api_client, permission_manage_products,
+        permission_manage_orders, product):
+    query = """
+    query VariantRevenue($id: ID!) {
+        productVariant(id: $id) {
+            revenue(period: TODAY) {
+                gross {
+                    localized
+                }
+            }
+        }
+    }
+    """
+    variant = product.variants.first()
+    variables = {
+        'id': graphene.Node.to_global_id('ProductVariant', variant.pk)}
+    permissions = [permission_manage_orders, permission_manage_products]
+    response = staff_api_client.post_graphql(query, variables, permissions)
+    content = get_graphql_content(response)
+    assert content['data']['productVariant']['revenue']
+
+
+def test_variant_quantity_ordered_permissions(
+        staff_api_client, permission_manage_products,
+        permission_manage_orders, product):
+    query = """
+    query QuantityOrdered($id: ID!) {
+        productVariant(id: $id) {
+            quantityOrdered
+        }
+    }
+    """
+    variant = product.variants.first()
+    variables = {
+        'id': graphene.Node.to_global_id('ProductVariant', variant.pk)}
+    permissions = [permission_manage_orders, permission_manage_products]
+    response = staff_api_client.post_graphql(query, variables, permissions)
+    content = get_graphql_content(response)
+    assert 'quantityOrdered' in content['data']['productVariant']
+
+
+def test_variant_margin_permissions(
+        staff_api_client, permission_manage_products,
+        permission_manage_orders, product):
+    query = """
+    query Margin($id: ID!) {
+        productVariant(id: $id) {
+            margin
+        }
+    }
+    """
+    variant = product.variants.first()
+    variables = {
+        'id': graphene.Node.to_global_id('ProductVariant', variant.pk)}
+    permissions = [permission_manage_orders, permission_manage_products]
+    response = staff_api_client.post_graphql(query, variables, permissions)
+    content = get_graphql_content(response)
+    assert 'margin' in content['data']['productVariant']