From 4f65ed7835956190d0a981da0866b46d7f53d3f2 Mon Sep 17 00:00:00 2001 From: Juha Louhiranta Date: Wed, 10 Apr 2024 09:44:53 +0300 Subject: [PATCH] feat: disable graphql introspection queries GraphQL queries starting with `__` e.g. `__schema` will be disabled by default. Refs: HP-2350 --- README.md | 1 + docs/config.adoc | 3 +- open_city_profile/settings.py | 5 ++- open_city_profile/tests/conftest.py | 5 +++ .../tests/test_graphql_api_schema.py | 27 +++++++++++++++- open_city_profile/views.py | 32 ++++++++++++++++++- 6 files changed, 69 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index cf2b75d3..f7e97af5 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ Prerequisites: * `CREATE_SUPERUSER`, creates a superuser with credentials `admin`:`admin` (admin@example.com) * `APPLY_MIGRATIONS`, applies migrations on startup * `ENABLE_GRAPHIQL`, enables GraphiQL interface for `/graphql/` + * `ENABLE_GRAPHQL_INTROSPECTION`, enables GraphQL introspection queries * `SEED_DEVELOPMENT_DATA`, flush data and recreate the environment with fake development data (requires `APPLY_MIGRATIONS`) * `OIDC_CLIENT_ID`, Tunnistamo client id for enabling GDPR API authorization code flows diff --git a/docs/config.adoc b/docs/config.adoc index 9b8aedef..64ca9f96 100644 --- a/docs/config.adoc +++ b/docs/config.adoc @@ -103,6 +103,7 @@ Common environment variable that is required in either case: == Feature flags - `ENABLE_GRAPHIQL`: Enables GraphiQL testing user interface. If `DEBUG` is `True`, this setting has no effect and GraphiQL is always enabled. Default is `False`. +- `ENABLE_GRAPHQL_INTROSPECTION`: Enables GraphQL introspection queries. If `DEBUG` is `True`, this setting has no effect and introspection queries are always enabled. Default is `False`. - `USE_X_FORWARDED_FOR`: Affects the way how a requester's IP address is figured out. If set to `True`, the `X-Forwarded-For` HTTP header is used as one option. Default is `False`. == Sentry @@ -111,7 +112,7 @@ It's possible to report errors to Sentry. - `SENTRY_DSN`: Sets the https://docs.sentry.io/platforms/python/configuration/options/#dsn[Sentry DSN]. If this is not set, nothing is sent to Sentry. - `SENTRY_ENVIRONMENT`: Sets the https://docs.sentry.io/platforms/python/configuration/options/#environment[Sentry environment]. Default is "development". -- `COMMIT_HASH`: Sets the https://docs.sentry.io/platforms/python/configuration/options/#release[Sentry release]. See `COMMIT_HASH` in <>. If `COMMIT_HASH` is not set, set module version instead. +- `COMMIT_HASH`: Sets the https://docs.sentry.io/platforms/python/configuration/options/#release[Sentry release]. See `COMMIT_HASH` in <>. If `COMMIT_HASH` is not set, set module version instead. == Miscellaneous diff --git a/open_city_profile/settings.py b/open_city_profile/settings.py index edac303f..064ada9c 100644 --- a/open_city_profile/settings.py +++ b/open_city_profile/settings.py @@ -51,6 +51,7 @@ AUDIT_LOG_TO_DB_ENABLED=(bool, False), OPEN_CITY_PROFILE_LOG_LEVEL=(str, None), ENABLE_GRAPHIQL=(bool, False), + ENABLE_GRAPHQL_INTROSPECTION=(bool, False), FORCE_SCRIPT_NAME=(str, ""), CSRF_COOKIE_NAME=(str, ""), CSRF_COOKIE_PATH=(str, ""), @@ -155,8 +156,10 @@ DEFAULT_FROM_EMAIL = env.str("DEFAULT_FROM_EMAIL") -# Set to True to enable GraphiQL interface, this will overriden to True if DEBUG=True +# Set to True to enable GraphiQL interface, enabled automatically if DEBUG=True ENABLE_GRAPHIQL = env("ENABLE_GRAPHIQL") +# Enable GraphQL introspection queries, enabled automatically if DEBUG=True +ENABLE_GRAPHQL_INTROSPECTION = env("ENABLE_GRAPHQL_INTROSPECTION") INSTALLED_APPS = [ "helusers.apps.HelusersConfig", diff --git a/open_city_profile/tests/conftest.py b/open_city_profile/tests/conftest.py index 9684d075..b3849328 100644 --- a/open_city_profile/tests/conftest.py +++ b/open_city_profile/tests/conftest.py @@ -120,6 +120,11 @@ def email_setup(settings): settings.EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend" +@pytest.fixture(autouse=True) +def enable_instrospection_query(settings): + settings.ENABLE_GRAPHQL_INTROSPECTION = True + + @pytest.fixture def keycloak_setup(settings): settings.KEYCLOAK_BASE_URL = "https://localhost/keycloak" diff --git a/open_city_profile/tests/test_graphql_api_schema.py b/open_city_profile/tests/test_graphql_api_schema.py index d4244be8..c5a11b0b 100644 --- a/open_city_profile/tests/test_graphql_api_schema.py +++ b/open_city_profile/tests/test_graphql_api_schema.py @@ -1,7 +1,32 @@ -from graphql import print_schema +import pytest +import requests +from graphql import get_introspection_query, print_schema def test_graphql_schema_matches_the_reference(gql_schema, snapshot): actual_schema = print_schema(gql_schema) snapshot.assert_match(actual_schema) + + +@pytest.mark.parametrize("enabled", [True, False]) +def test_graphql_schema_introspection_can_be_disabled(live_server, settings, enabled): + settings.ENABLE_GRAPHQL_INTROSPECTION = enabled + url = live_server.url + "/graphql/" + payload = { + "query": get_introspection_query(), + } + + response = requests.post(url, json=payload) + + body = response.json() + if enabled: + assert response.status_code == 200 + assert "errors" not in body + assert "__schema" in body["data"] + else: + assert response.status_code == 400 + assert "data" not in body + error = body["errors"][0]["message"] + assert "__schema" in error + assert "introspection is disabled" in error diff --git a/open_city_profile/views.py b/open_city_profile/views.py index 3648ec51..b34c1a35 100644 --- a/open_city_profile/views.py +++ b/open_city_profile/views.py @@ -1,7 +1,10 @@ import graphene_validator.errors import sentry_sdk +from django.conf import settings from django.core.exceptions import ObjectDoesNotExist, PermissionDenied, ValidationError +from graphene.validation import DisableIntrospection from graphene_django.views import GraphQLView as BaseGraphQLView +from graphql import ExecutionResult, parse, validate from helusers.oidc import AuthenticationError from open_city_profile.consts import ( @@ -90,9 +93,36 @@ def _get_error_code(exception): class GraphQLView(BaseGraphQLView): + + def _run_custom_validator(self, query): + result = None + if not settings.ENABLE_GRAPHQL_INTROSPECTION and not settings.DEBUG: + try: + document = parse(query) + except Exception: + # Execution will also fail in super().execute_graphql_request() + # when parsing the query so no need to do anything here. + pass + else: + validation_errors = validate( + schema=self.schema.graphql_schema, + document_ast=document, + rules=(DisableIntrospection,), + ) + if validation_errors: + result = ExecutionResult(data=None, errors=validation_errors) + + return result + def execute_graphql_request(self, request, data, query, *args, **kwargs): """Extract any exceptions and send some of them to Sentry""" - result = super().execute_graphql_request(request, data, query, *args, **kwargs) + result = self._run_custom_validator(query) + + if not result: + result = super().execute_graphql_request( + request, data, query, *args, **kwargs + ) + if result and result.errors: errors = [ e