graphql-http 2.0.4__tar.gz → 2.0.5__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.
- {graphql_http-2.0.4 → graphql_http-2.0.5}/PKG-INFO +1 -1
- graphql_http-2.0.5/VERSION +1 -0
- {graphql_http-2.0.4 → graphql_http-2.0.5}/graphql_http/server.py +245 -16
- {graphql_http-2.0.4 → graphql_http-2.0.5}/graphql_http.egg-info/PKG-INFO +1 -1
- {graphql_http-2.0.4 → graphql_http-2.0.5}/tests/test_auth.py +26 -4
- graphql_http-2.0.4/VERSION +0 -1
- {graphql_http-2.0.4 → graphql_http-2.0.5}/.coverage +0 -0
- {graphql_http-2.0.4 → graphql_http-2.0.5}/.github/workflows/python-package.yml +0 -0
- {graphql_http-2.0.4 → graphql_http-2.0.5}/.gitignore +0 -0
- {graphql_http-2.0.4 → graphql_http-2.0.5}/LICENSE +0 -0
- {graphql_http-2.0.4 → graphql_http-2.0.5}/README.md +0 -0
- {graphql_http-2.0.4 → graphql_http-2.0.5}/examples/README.md +0 -0
- {graphql_http-2.0.4 → graphql_http-2.0.5}/examples/advanced_server.py +0 -0
- {graphql_http-2.0.4 → graphql_http-2.0.5}/examples/auth_server.py +0 -0
- {graphql_http-2.0.4 → graphql_http-2.0.5}/examples/basic_server.py +0 -0
- {graphql_http-2.0.4 → graphql_http-2.0.5}/examples/graphql_api_server.py +0 -0
- {graphql_http-2.0.4 → graphql_http-2.0.5}/graphql_http/__init__.py +0 -0
- {graphql_http-2.0.4 → graphql_http-2.0.5}/graphql_http/error.py +0 -0
- {graphql_http-2.0.4 → graphql_http-2.0.5}/graphql_http/graphiql/index.html +0 -0
- {graphql_http-2.0.4 → graphql_http-2.0.5}/graphql_http/helpers.py +0 -0
- {graphql_http-2.0.4 → graphql_http-2.0.5}/graphql_http.egg-info/SOURCES.txt +0 -0
- {graphql_http-2.0.4 → graphql_http-2.0.5}/graphql_http.egg-info/dependency_links.txt +0 -0
- {graphql_http-2.0.4 → graphql_http-2.0.5}/graphql_http.egg-info/requires.txt +0 -0
- {graphql_http-2.0.4 → graphql_http-2.0.5}/graphql_http.egg-info/top_level.txt +0 -0
- {graphql_http-2.0.4 → graphql_http-2.0.5}/pyproject.toml +0 -0
- {graphql_http-2.0.4 → graphql_http-2.0.5}/setup.cfg +0 -0
- {graphql_http-2.0.4 → graphql_http-2.0.5}/test_server.py +0 -0
- {graphql_http-2.0.4 → graphql_http-2.0.5}/tests/__init__.py +0 -0
- {graphql_http-2.0.4 → graphql_http-2.0.5}/tests/app.py +0 -0
- {graphql_http-2.0.4 → graphql_http-2.0.5}/tests/app_federation.py +0 -0
- {graphql_http-2.0.4 → graphql_http-2.0.5}/tests/conftest.py +0 -0
- {graphql_http-2.0.4 → graphql_http-2.0.5}/tests/test_cors.py +0 -0
- {graphql_http-2.0.4 → graphql_http-2.0.5}/tests/test_federation.py +0 -0
- {graphql_http-2.0.4 → graphql_http-2.0.5}/tests/test_graphql_api.py +0 -0
- {graphql_http-2.0.4 → graphql_http-2.0.5}/tests/test_server.py +0 -0
- {graphql_http-2.0.4 → graphql_http-2.0.5}/uv.lock +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
2.0.5
|
|
@@ -320,36 +320,265 @@ 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
|
+
|
|
498
|
+
def enter_selection_set(self, node, *_):
|
|
499
|
+
self.depth += 1
|
|
500
|
+
|
|
501
|
+
def leave_selection_set(self, node, *_):
|
|
502
|
+
self.depth -= 1
|
|
503
|
+
|
|
504
|
+
def enter_field(self, node: FieldNode, *_):
|
|
505
|
+
# Only collect fields at depth 1 (root level fields)
|
|
506
|
+
if self.depth == 1 and hasattr(node, 'name') and hasattr(node.name, 'value'):
|
|
507
|
+
root_field_names.add(node.name.value)
|
|
508
|
+
|
|
509
|
+
visit(document, RootFieldCollector())
|
|
510
|
+
|
|
511
|
+
# Check if all root fields are introspection fields
|
|
512
|
+
introspection_fields = {'__schema', '__type', '__typename'}
|
|
513
|
+
|
|
514
|
+
# If no fields found, it's not a valid query
|
|
515
|
+
if not root_field_names:
|
|
516
|
+
return False
|
|
517
|
+
|
|
518
|
+
# All root fields must be introspection fields
|
|
519
|
+
return root_field_names.issubset(introspection_fields)
|
|
520
|
+
|
|
521
|
+
except Exception:
|
|
522
|
+
# If AST parsing fails, it's not a valid query - BLOCK IT
|
|
523
|
+
return False
|
|
524
|
+
|
|
525
|
+
def _check_introspection_only_fallback(self, data: Union[Dict, List]) -> bool:
|
|
526
|
+
"""Fallback string-based introspection check for when GraphQL parsing fails.
|
|
324
527
|
|
|
325
528
|
Args:
|
|
326
529
|
data: Request data
|
|
327
530
|
|
|
328
531
|
Returns:
|
|
329
|
-
True if request
|
|
532
|
+
True if request appears to be introspection-only (conservative check)
|
|
330
533
|
"""
|
|
534
|
+
# Handle batched queries
|
|
535
|
+
if isinstance(data, list):
|
|
536
|
+
return all(self._check_introspection_only_fallback(item) for item in data)
|
|
331
537
|
|
|
332
|
-
|
|
333
|
-
|
|
538
|
+
if not isinstance(data, dict) or 'query' not in data:
|
|
539
|
+
return False
|
|
540
|
+
|
|
541
|
+
query_str = data.get('query', '')
|
|
542
|
+
if not query_str or not isinstance(query_str, str):
|
|
543
|
+
return False
|
|
544
|
+
|
|
545
|
+
query_lower = query_str.lower()
|
|
334
546
|
|
|
335
547
|
# Check for introspection fields
|
|
336
|
-
introspection_fields = [
|
|
337
|
-
has_introspection = any(
|
|
338
|
-
f in query_data_lower for f in introspection_fields)
|
|
548
|
+
introspection_fields = ['__schema', '__type', '__typename']
|
|
549
|
+
has_introspection = any(field in query_lower for field in introspection_fields)
|
|
339
550
|
|
|
340
551
|
if not has_introspection:
|
|
341
552
|
return False
|
|
342
553
|
|
|
343
|
-
#
|
|
344
|
-
#
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
)
|
|
554
|
+
# Conservative approach: check for common non-introspection patterns
|
|
555
|
+
# Remove comments and strings to avoid false positives
|
|
556
|
+
import re
|
|
557
|
+
clean_query = re.sub(r'#.*', '', query_str) # Remove comments
|
|
558
|
+
clean_query = re.sub(r'"[^"]*"', '', clean_query) # Remove string literals
|
|
559
|
+
clean_query = re.sub(r"'[^']*'", '', clean_query) # Remove string literals
|
|
560
|
+
clean_query = clean_query.lower()
|
|
561
|
+
|
|
562
|
+
# Look for non-introspection field patterns in clean query
|
|
563
|
+
# Use word boundaries to avoid matching substrings
|
|
564
|
+
suspicious_patterns = [
|
|
565
|
+
r'\b(query|mutation|subscription)\s+\w+', # Named operations
|
|
566
|
+
r'\{[^}]*[a-z_][a-z0-9_]*\s*[({]', # Regular field selections
|
|
567
|
+
]
|
|
568
|
+
|
|
569
|
+
# If we find suspicious patterns, be conservative and require auth
|
|
570
|
+
for pattern in suspicious_patterns:
|
|
571
|
+
if re.search(pattern, clean_query):
|
|
572
|
+
# Double-check it's not just introspection
|
|
573
|
+
remaining = clean_query
|
|
574
|
+
for intro_field in introspection_fields:
|
|
575
|
+
remaining = remaining.replace(intro_field, '')
|
|
576
|
+
|
|
577
|
+
# If there's still substantial content, likely has regular fields
|
|
578
|
+
if len(re.findall(r'\w+', remaining)) > 3: # Threshold for field names
|
|
579
|
+
return False
|
|
350
580
|
|
|
351
|
-
|
|
352
|
-
return has_introspection and not has_regular_fields
|
|
581
|
+
return True
|
|
353
582
|
|
|
354
583
|
def _authenticate_request(self, request: Request) -> Optional[Response]:
|
|
355
584
|
"""Authenticate JWT token from request.
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
graphql_http-2.0.4/VERSION
DELETED
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|