graphql-http 2.0.4__tar.gz → 2.0.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. {graphql_http-2.0.4 → graphql_http-2.0.6}/PKG-INFO +1 -1
  2. graphql_http-2.0.6/VERSION +1 -0
  3. {graphql_http-2.0.4 → graphql_http-2.0.6}/graphql_http/server.py +260 -16
  4. {graphql_http-2.0.4 → graphql_http-2.0.6}/graphql_http.egg-info/PKG-INFO +1 -1
  5. {graphql_http-2.0.4 → graphql_http-2.0.6}/tests/test_auth.py +67 -4
  6. graphql_http-2.0.4/VERSION +0 -1
  7. {graphql_http-2.0.4 → graphql_http-2.0.6}/.coverage +0 -0
  8. {graphql_http-2.0.4 → graphql_http-2.0.6}/.github/workflows/python-package.yml +0 -0
  9. {graphql_http-2.0.4 → graphql_http-2.0.6}/.gitignore +0 -0
  10. {graphql_http-2.0.4 → graphql_http-2.0.6}/LICENSE +0 -0
  11. {graphql_http-2.0.4 → graphql_http-2.0.6}/README.md +0 -0
  12. {graphql_http-2.0.4 → graphql_http-2.0.6}/examples/README.md +0 -0
  13. {graphql_http-2.0.4 → graphql_http-2.0.6}/examples/advanced_server.py +0 -0
  14. {graphql_http-2.0.4 → graphql_http-2.0.6}/examples/auth_server.py +0 -0
  15. {graphql_http-2.0.4 → graphql_http-2.0.6}/examples/basic_server.py +0 -0
  16. {graphql_http-2.0.4 → graphql_http-2.0.6}/examples/graphql_api_server.py +0 -0
  17. {graphql_http-2.0.4 → graphql_http-2.0.6}/graphql_http/__init__.py +0 -0
  18. {graphql_http-2.0.4 → graphql_http-2.0.6}/graphql_http/error.py +0 -0
  19. {graphql_http-2.0.4 → graphql_http-2.0.6}/graphql_http/graphiql/index.html +0 -0
  20. {graphql_http-2.0.4 → graphql_http-2.0.6}/graphql_http/helpers.py +0 -0
  21. {graphql_http-2.0.4 → graphql_http-2.0.6}/graphql_http.egg-info/SOURCES.txt +0 -0
  22. {graphql_http-2.0.4 → graphql_http-2.0.6}/graphql_http.egg-info/dependency_links.txt +0 -0
  23. {graphql_http-2.0.4 → graphql_http-2.0.6}/graphql_http.egg-info/requires.txt +0 -0
  24. {graphql_http-2.0.4 → graphql_http-2.0.6}/graphql_http.egg-info/top_level.txt +0 -0
  25. {graphql_http-2.0.4 → graphql_http-2.0.6}/pyproject.toml +0 -0
  26. {graphql_http-2.0.4 → graphql_http-2.0.6}/setup.cfg +0 -0
  27. {graphql_http-2.0.4 → graphql_http-2.0.6}/test_server.py +0 -0
  28. {graphql_http-2.0.4 → graphql_http-2.0.6}/tests/__init__.py +0 -0
  29. {graphql_http-2.0.4 → graphql_http-2.0.6}/tests/app.py +0 -0
  30. {graphql_http-2.0.4 → graphql_http-2.0.6}/tests/app_federation.py +0 -0
  31. {graphql_http-2.0.4 → graphql_http-2.0.6}/tests/conftest.py +0 -0
  32. {graphql_http-2.0.4 → graphql_http-2.0.6}/tests/test_cors.py +0 -0
  33. {graphql_http-2.0.4 → graphql_http-2.0.6}/tests/test_federation.py +0 -0
  34. {graphql_http-2.0.4 → graphql_http-2.0.6}/tests/test_graphql_api.py +0 -0
  35. {graphql_http-2.0.4 → graphql_http-2.0.6}/tests/test_server.py +0 -0
  36. {graphql_http-2.0.4 → graphql_http-2.0.6}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graphql_http
3
- Version: 2.0.4
3
+ Version: 2.0.6
4
4
  Summary: HTTP Server for GraphQL.
5
5
  Author-email: Robert Parker <rob@parob.com>
6
6
  License: MIT
@@ -0,0 +1 @@
1
+ 2.0.6
@@ -320,36 +320,280 @@ class GraphQLHTTP:
320
320
  return PlainTextResponse("OK", headers=response_headers)
321
321
 
322
322
  def _check_introspection_only(self, data: Union[Dict, List]) -> bool:
323
- """Check if request contains only introspection queries.
323
+ """Check if request contains only introspection queries using GraphQL's built-in validation.
324
+
325
+ This approach uses GraphQL's execution preparation to determine if a query
326
+ is introspection-only by checking what fields would actually be resolved.
327
+
328
+ Args:
329
+ data: Request data (dict for single query, list for batched queries)
330
+
331
+ Returns:
332
+ True if all queries are introspection-only
333
+ """
334
+ try:
335
+ from graphql import parse, validate
336
+ from graphql.type.introspection import is_introspection_type
337
+ # Try to import execution context functions, fall back gracefully if not available
338
+ try:
339
+ from graphql.execution import build_execution_context
340
+ from graphql.execution.collect_fields import collect_fields
341
+ has_execution_context = True
342
+ except ImportError:
343
+ has_execution_context = False
344
+ except ImportError:
345
+ # Fallback to AST-based check if advanced GraphQL features not available
346
+ return self._check_introspection_only_ast(data)
347
+
348
+ # Handle batched queries
349
+ if isinstance(data, list):
350
+ return all(self._check_introspection_only(item) for item in data)
351
+
352
+ if not isinstance(data, dict) or 'query' not in data:
353
+ return False
354
+
355
+ query_str = data.get('query', '')
356
+ if not query_str or not isinstance(query_str, str):
357
+ return False
358
+
359
+ # Pre-validation security checks
360
+ query_stripped = query_str.strip()
361
+ if not query_stripped:
362
+ return False
363
+
364
+ # Basic syntax validation - ensure query looks structurally sound
365
+ if query_stripped.count('{') != query_stripped.count('}'):
366
+ return False
367
+ if query_stripped.count('(') != query_stripped.count(')'):
368
+ return False
369
+
370
+ try:
371
+ # Parse and validate the query - this will catch syntax errors
372
+ document = parse(query_str)
373
+ validation_errors = validate(self.schema, document)
374
+
375
+ # If query has validation errors, it's not a valid introspection query
376
+ if validation_errors:
377
+ return False
378
+
379
+ # If execution context is not available, fall back to AST method
380
+ if not has_execution_context:
381
+ return self._check_introspection_only_ast(data)
382
+
383
+ # Additional security checks before execution analysis
384
+ # Check if document contains only query operations (no mutations/subscriptions)
385
+ for definition in document.definitions:
386
+ if hasattr(definition, 'operation'):
387
+ # Only allow query operations for introspection bypass
388
+ if definition.operation != 'query':
389
+ return False
390
+
391
+ # Build execution context to analyze what fields would be resolved
392
+ try:
393
+ context_value = {}
394
+ variable_values = data.get('variables') or {}
395
+ operation_name = data.get('operationName')
396
+
397
+ exe_context = build_execution_context(
398
+ self.schema,
399
+ document,
400
+ context_value=context_value,
401
+ variable_values=variable_values,
402
+ operation_name=operation_name
403
+ )
404
+
405
+ # If execution context building fails, be conservative
406
+ if isinstance(exe_context, list): # List of GraphQLError
407
+ return False
408
+
409
+ # Get the operation
410
+ operation = exe_context.operation
411
+ if not operation or not operation.selection_set:
412
+ return False
413
+
414
+ # Collect the fields that would be executed
415
+ fields = collect_fields(
416
+ exe_context,
417
+ exe_context.schema.query_type,
418
+ operation.selection_set,
419
+ set(),
420
+ set()
421
+ )
422
+
423
+ # Strict validation: only allow official introspection fields
424
+ official_introspection_fields = {'__schema', '__type', '__typename'}
425
+
426
+ for field_name in fields.keys():
427
+ # Must start with __ (introspection convention)
428
+ if not field_name.startswith('__'):
429
+ return False
430
+
431
+ # Must be an official introspection field
432
+ if field_name not in official_introspection_fields:
433
+ return False
434
+
435
+ # Double-check by examining the field types being accessed
436
+ query_type = self.schema.query_type
437
+ for field_name in fields.keys():
438
+ field_def = query_type.fields.get(field_name)
439
+ if field_def:
440
+ field_type = field_def.type
441
+ # Unwrap non-null and list types to get the base type
442
+ while hasattr(field_type, 'of_type'):
443
+ field_type = field_type.of_type
444
+
445
+ # Check if the field type is an introspection type
446
+ if not is_introspection_type(field_type):
447
+ return False
448
+ else:
449
+ # Field doesn't exist in schema - should not happen with validation
450
+ return False
451
+
452
+ return True
453
+
454
+ except Exception:
455
+ # If execution context fails, fall back to AST method
456
+ return self._check_introspection_only_ast(data)
457
+
458
+ except Exception:
459
+ # If parsing fails, it's not a valid query - BLOCK IT
460
+ # Parse errors indicate malformed GraphQL which should never bypass auth
461
+ return False
462
+
463
+ def _check_introspection_only_ast(self, data: Union[Dict, List]) -> bool:
464
+ """AST-based fallback for introspection detection.
465
+
466
+ This is the previous implementation as a fallback when execution context
467
+ analysis is not available.
468
+ """
469
+ try:
470
+ from graphql import parse, FieldNode, visit, Visitor
471
+ except ImportError:
472
+ # Final fallback to string-based check
473
+ return self._check_introspection_only_fallback(data)
474
+
475
+ # Handle batched queries
476
+ if isinstance(data, list):
477
+ return all(self._check_introspection_only_ast(item) for item in data)
478
+
479
+ if not isinstance(data, dict) or 'query' not in data:
480
+ return False
481
+
482
+ query_str = data.get('query', '')
483
+ if not query_str or not isinstance(query_str, str):
484
+ return False
485
+
486
+ try:
487
+ # Parse the GraphQL query into an AST
488
+ document = parse(query_str)
489
+
490
+ # Extract only root-level field names from the query
491
+ root_field_names = set()
492
+
493
+ class RootFieldCollector(Visitor):
494
+ def __init__(self):
495
+ super().__init__()
496
+ self.depth = 0
497
+ self.in_operation = False
498
+
499
+ def enter_operation_definition(self, node, *_):
500
+ # We're now inside an operation (query/mutation/subscription)
501
+ self.in_operation = True
502
+
503
+ def leave_operation_definition(self, node, *_):
504
+ # Leaving the operation
505
+ self.in_operation = False
506
+
507
+ def enter_fragment_definition(self, node, *_):
508
+ # Skip fragment definitions - they're not part of the actual query execution
509
+ return False
510
+
511
+ def enter_selection_set(self, node, *_):
512
+ if self.in_operation:
513
+ self.depth += 1
514
+
515
+ def leave_selection_set(self, node, *_):
516
+ if self.in_operation:
517
+ self.depth -= 1
518
+
519
+ def enter_field(self, node: FieldNode, *_):
520
+ # Only collect fields at depth 1 within actual operations
521
+ if self.in_operation and self.depth == 1 and hasattr(node, 'name') and hasattr(node.name, 'value'):
522
+ root_field_names.add(node.name.value)
523
+
524
+ visit(document, RootFieldCollector())
525
+
526
+ # Check if all root fields are introspection fields
527
+ introspection_fields = {'__schema', '__type', '__typename'}
528
+
529
+ # If no fields found, it's not a valid query
530
+ if not root_field_names:
531
+ return False
532
+
533
+ # All root fields must be introspection fields
534
+ return root_field_names.issubset(introspection_fields)
535
+
536
+ except Exception:
537
+ # If AST parsing fails, it's not a valid query - BLOCK IT
538
+ return False
539
+
540
+ def _check_introspection_only_fallback(self, data: Union[Dict, List]) -> bool:
541
+ """Fallback string-based introspection check for when GraphQL parsing fails.
324
542
 
325
543
  Args:
326
544
  data: Request data
327
545
 
328
546
  Returns:
329
- True if request is introspection-only
547
+ True if request appears to be introspection-only (conservative check)
330
548
  """
549
+ # Handle batched queries
550
+ if isinstance(data, list):
551
+ return all(self._check_introspection_only_fallback(item) for item in data)
331
552
 
332
- # Simple heuristic: if it contains introspection fields AND no regular fields
333
- query_data_lower = str(data).lower()
553
+ if not isinstance(data, dict) or 'query' not in data:
554
+ return False
555
+
556
+ query_str = data.get('query', '')
557
+ if not query_str or not isinstance(query_str, str):
558
+ return False
559
+
560
+ query_lower = query_str.lower()
334
561
 
335
562
  # Check for introspection fields
336
- introspection_fields = ["__schema", "__type", "__typename"]
337
- has_introspection = any(
338
- f in query_data_lower for f in introspection_fields)
563
+ introspection_fields = ['__schema', '__type', '__typename']
564
+ has_introspection = any(field in query_lower for field in introspection_fields)
339
565
 
340
566
  if not has_introspection:
341
567
  return False
342
568
 
343
- # Check for regular fields (non-introspection)
344
- # This is a simple heuristic - in a mixed query, we require auth
345
- regular_field_patterns = [
346
- "hello", "users", "posts", "data", "mutation"]
347
- has_regular_fields = any(
348
- pattern in query_data_lower for pattern in regular_field_patterns
349
- )
569
+ # Conservative approach: check for common non-introspection patterns
570
+ # Remove comments and strings to avoid false positives
571
+ import re
572
+ clean_query = re.sub(r'#.*', '', query_str) # Remove comments
573
+ clean_query = re.sub(r'"[^"]*"', '', clean_query) # Remove string literals
574
+ clean_query = re.sub(r"'[^']*'", '', clean_query) # Remove string literals
575
+ clean_query = clean_query.lower()
576
+
577
+ # Look for non-introspection field patterns in clean query
578
+ # Use word boundaries to avoid matching substrings
579
+ suspicious_patterns = [
580
+ r'\b(query|mutation|subscription)\s+\w+', # Named operations
581
+ r'\{[^}]*[a-z_][a-z0-9_]*\s*[({]', # Regular field selections
582
+ ]
583
+
584
+ # If we find suspicious patterns, be conservative and require auth
585
+ for pattern in suspicious_patterns:
586
+ if re.search(pattern, clean_query):
587
+ # Double-check it's not just introspection
588
+ remaining = clean_query
589
+ for intro_field in introspection_fields:
590
+ remaining = remaining.replace(intro_field, '')
591
+
592
+ # If there's still substantial content, likely has regular fields
593
+ if len(re.findall(r'\w+', remaining)) > 3: # Threshold for field names
594
+ return False
350
595
 
351
- # Only allow introspection-only queries to bypass auth
352
- return has_introspection and not has_regular_fields
596
+ return True
353
597
 
354
598
  def _authenticate_request(self, request: Request) -> Optional[Response]:
355
599
  """Authenticate JWT token from request.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graphql_http
3
- Version: 2.0.4
3
+ Version: 2.0.6
4
4
  Summary: HTTP Server for GraphQL.
5
5
  Author-email: Robert Parker <rob@parob.com>
6
6
  License: MIT
@@ -162,7 +162,7 @@ class TestGraphQLHTTPAuthentication:
162
162
  assert "errors" in result
163
163
  assert "Invalid token" in result["errors"][0]["message"]
164
164
 
165
- def test_introspection_without_auth_when_disabled(self, schema):
165
+ def test_introspection_without_auth_bypass(self, schema):
166
166
  """Test introspection queries work without auth when auth_introspection_bypass=False."""
167
167
  server = GraphQLHTTP(
168
168
  schema=schema,
@@ -184,7 +184,7 @@ class TestGraphQLHTTPAuthentication:
184
184
  assert "errors" in result
185
185
  assert "Authorization header is missing" in result["errors"][0]["message"]
186
186
 
187
- def test_introspection_with_auth_when_enabled(self, schema):
187
+ def test_introspection_with_auth_bypass(self, schema):
188
188
  """Test introspection queries require auth when auth_introspection_bypass=True."""
189
189
 
190
190
  server = GraphQLHTTP(
@@ -197,7 +197,7 @@ class TestGraphQLHTTPAuthentication:
197
197
  )
198
198
  client = server.client()
199
199
 
200
- # Introspection query should require auth
200
+ # Introspection query not require auth
201
201
  response = client.post(
202
202
  "/graphql",
203
203
  json={"query": "{ __schema { queryType { name } } }"}
@@ -205,7 +205,7 @@ class TestGraphQLHTTPAuthentication:
205
205
  assert response.status_code == 200
206
206
  # Should get a valid introspection response, not an auth error
207
207
 
208
- def test_regular_query_requires_auth(self, schema):
208
+ def test_regular_query_without_auth_bypass(self, schema):
209
209
  """Test that regular queries always require auth when auth is enabled."""
210
210
  server = GraphQLHTTP(
211
211
  schema=schema,
@@ -227,6 +227,28 @@ class TestGraphQLHTTPAuthentication:
227
227
  assert "errors" in result
228
228
  assert "Authorization header is missing" in result["errors"][0]["message"]
229
229
 
230
+ def test_regular_query_with_auth_bypass(self, schema):
231
+ """Test that regular queries always require auth when auth is enabled."""
232
+ server = GraphQLHTTP(
233
+ schema=schema,
234
+ auth_enabled=True,
235
+ auth_bypass_during_introspection=True, # Only introspection is exempt
236
+ auth_jwks_uri="https://example.com/.well-known/jwks.json",
237
+ auth_issuer="https://example.com/",
238
+ auth_audience="test-audience"
239
+ )
240
+ client = server.client()
241
+
242
+ # Regular query should require auth
243
+ response = client.post(
244
+ "/graphql",
245
+ json={"query": "{ hello }"}
246
+ )
247
+ assert response.status_code == 401
248
+ result = response.json()
249
+ assert "errors" in result
250
+ assert "Authorization header is missing" in result["errors"][0]["message"]
251
+
230
252
  def test_jwks_client_initialization_failure(self, schema):
231
253
  """Test server initialization when JWKS URI is not provided."""
232
254
  # This should raise a ValueError during initialization now due to validation
@@ -288,3 +310,44 @@ class TestGraphQLHTTPAuthentication:
288
310
  result = response.json()
289
311
  assert "errors" in result
290
312
  assert "Authorization header is missing" in result["errors"][0]["message"]
313
+
314
+ def test_full_introspection_query_with_auth_bypass(self, schema):
315
+ """Test that the standard full introspection query works with auth bypass.
316
+
317
+ This tests the large introspection query that GraphiQL and other tools use
318
+ to fetch the complete schema information.
319
+ """
320
+ from graphql import get_introspection_query
321
+
322
+ server = GraphQLHTTP(
323
+ schema=schema,
324
+ auth_enabled=True,
325
+ auth_bypass_during_introspection=True,
326
+ auth_jwks_uri="https://example.com/.well-known/jwks.json",
327
+ auth_issuer="https://example.com/",
328
+ auth_audience="test-audience"
329
+ )
330
+ client = server.client()
331
+
332
+ # Get the standard introspection query
333
+ introspection_query = get_introspection_query()
334
+
335
+ # This should work without authentication
336
+ response = client.post(
337
+ "/graphql",
338
+ json={"query": introspection_query}
339
+ )
340
+
341
+ assert response.status_code == 200
342
+ result = response.json()
343
+
344
+ # Should get valid introspection data, not an auth error
345
+ assert "errors" not in result or len(result["errors"]) == 0
346
+ assert "data" in result
347
+ assert "__schema" in result["data"]
348
+
349
+ # Verify we get expected introspection structure
350
+ schema_data = result["data"]["__schema"]
351
+ assert "queryType" in schema_data
352
+ assert "types" in schema_data
353
+ assert "directives" in schema_data
@@ -1 +0,0 @@
1
- 2.0.4
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes