@@ -246,6 +246,232 @@ def waf_score(value, ua=None, level=0):
246246 retVal += WAF_SCANNER_UA_WEIGHT
247247 return retVal
248248
249+ # --- GraphQL endpoint (vulnerable Apollo-style, backed by the same SQLite database) ----------
250+
251+ # Hard-coded introspection response matching the schema below. Every GraphQL tool (including
252+ # sqlmap's --graphql engine) uses this to discover fields, arguments, and types.
253+ def _graphql_introspection ():
254+ return {
255+ "data" : {
256+ "__schema" : {
257+ "queryType" : {"name" : "Query" },
258+ "mutationType" : {"name" : "Mutation" },
259+ "subscriptionType" : None ,
260+ "directives" : [],
261+ "types" : [
262+ {"kind" : "OBJECT" , "name" : "Query" , "fields" : [
263+ {"name" : "user" , "args" : [
264+ {"name" : "username" , "defaultValue" : None , "type" : {"kind" : "NON_NULL" , "name" : None , "ofType" : {"kind" : "SCALAR" , "name" : "String" , "ofType" : None }}}
265+ ], "type" : {"kind" : "OBJECT" , "name" : "User" , "ofType" : None }},
266+ {"name" : "search" , "args" : [
267+ {"name" : "term" , "defaultValue" : None , "type" : {"kind" : "SCALAR" , "name" : "String" , "ofType" : None }}
268+ ], "type" : {"kind" : "LIST" , "name" : None , "ofType" : {"kind" : "OBJECT" , "name" : "User" , "ofType" : None }}},
269+ {"name" : "login" , "args" : [
270+ {"name" : "username" , "defaultValue" : None , "type" : {"kind" : "NON_NULL" , "name" : None , "ofType" : {"kind" : "SCALAR" , "name" : "String" , "ofType" : None }}},
271+ {"name" : "password" , "defaultValue" : None , "type" : {"kind" : "NON_NULL" , "name" : None , "ofType" : {"kind" : "SCALAR" , "name" : "String" , "ofType" : None }}}
272+ ], "type" : {"kind" : "OBJECT" , "name" : "AuthPayload" , "ofType" : None }},
273+ ], "inputFields" : None , "enumValues" : None },
274+ {"kind" : "OBJECT" , "name" : "Mutation" , "fields" : [
275+ {"name" : "updateUser" , "args" : [
276+ {"name" : "id" , "defaultValue" : None , "type" : {"kind" : "NON_NULL" , "name" : None , "ofType" : {"kind" : "SCALAR" , "name" : "Int" , "ofType" : None }}},
277+ {"name" : "email" , "defaultValue" : None , "type" : {"kind" : "NON_NULL" , "name" : None , "ofType" : {"kind" : "SCALAR" , "name" : "String" , "ofType" : None }}}
278+ ], "type" : {"kind" : "OBJECT" , "name" : "User" , "ofType" : None }},
279+ ], "inputFields" : None , "enumValues" : None },
280+ {"kind" : "INPUT_OBJECT" , "name" : "UpdateUserInput" , "inputFields" : [
281+ {"name" : "id" , "defaultValue" : None , "type" : {"kind" : "NON_NULL" , "name" : None , "ofType" : {"kind" : "SCALAR" , "name" : "Int" , "ofType" : None }}},
282+ {"name" : "email" , "defaultValue" : None , "type" : {"kind" : "NON_NULL" , "name" : None , "ofType" : {"kind" : "SCALAR" , "name" : "String" , "ofType" : None }}}
283+ ]},
284+ {"kind" : "SCALAR" , "name" : "Int" },
285+ {"kind" : "SCALAR" , "name" : "String" },
286+ {"kind" : "SCALAR" , "name" : "Boolean" },
287+ {"kind" : "SCALAR" , "name" : "Float" },
288+ {"kind" : "SCALAR" , "name" : "ID" },
289+ {"kind" : "OBJECT" , "name" : "User" , "fields" : [
290+ {"name" : "id" , "args" : [], "type" : {"kind" : "SCALAR" , "name" : "Int" , "ofType" : None }},
291+ {"name" : "name" , "args" : [], "type" : {"kind" : "SCALAR" , "name" : "String" , "ofType" : None }},
292+ {"name" : "surname" , "args" : [], "type" : {"kind" : "SCALAR" , "name" : "String" , "ofType" : None }},
293+ ], "inputFields" : None , "enumValues" : None },
294+ {"kind" : "OBJECT" , "name" : "AuthPayload" , "fields" : [
295+ {"name" : "token" , "args" : [], "type" : {"kind" : "SCALAR" , "name" : "String" , "ofType" : None }},
296+ {"name" : "user" , "args" : [], "type" : {"kind" : "OBJECT" , "name" : "User" , "ofType" : None }},
297+ ], "inputFields" : None , "enumValues" : None },
298+ ]
299+ }
300+ }
301+ }
302+
303+
304+ def _graphql_arg (raw ):
305+ """Parse a single GraphQL argument value: strip quotes from strings, keep numbers as-is"""
306+ raw = raw .strip ()
307+ if raw .startswith ('"' ) and raw .endswith ('"' ):
308+ return raw [1 :- 1 ].replace ('\\ "' , '"' )
309+ return raw
310+
311+
312+ def _graphql_match (text , start ):
313+ """Index just past the bracket matching the one at text[start] ('(' or '{'), skipping over
314+ double-quoted strings so brackets inside argument literals (e.g. an injected SQL payload) and
315+ nested selection sets do not throw off the balance."""
316+
317+ pairs = {'(' : ')' , '{' : '}' }
318+ opener , closer = text [start ], pairs [text [start ]]
319+ depth , i , n = 0 , start , len (text )
320+ while i < n :
321+ char = text [i ]
322+ if char == '"' :
323+ i += 1
324+ while i < n and text [i ] != '"' :
325+ i += 2 if text [i ] == '\\ ' else 1
326+ elif char == opener :
327+ depth += 1
328+ elif char == closer :
329+ depth -= 1
330+ if depth == 0 :
331+ return i + 1
332+ i += 1
333+ return n
334+
335+
336+ def _graphql_selections (body ):
337+ """Split a selection set into its top-level (alias, field, rawArgs) fields, tolerating aliasing,
338+ argument literals carrying brackets/quotes, and nested selection sets (which are skipped over)."""
339+
340+ identifier = re .compile (r'[A-Za-z_]\w*' )
341+ selections , i , n = [], 0 , len (body )
342+ while i < n :
343+ while i < n and body [i ] in ' \t \r \n ,' :
344+ i += 1
345+ match = identifier .match (body , i )
346+ if not match :
347+ i += 1
348+ continue
349+ name , i = match .group (0 ), match .end ()
350+
351+ j = i
352+ while j < n and body [j ] in ' \t \r \n ' :
353+ j += 1
354+ if j < n and body [j ] == ':' : # 'name' was an alias; the real field follows
355+ j += 1
356+ while j < n and body [j ] in ' \t \r \n ' :
357+ j += 1
358+ match = identifier .match (body , j )
359+ if not match :
360+ continue
361+ alias , field , i = name , match .group (0 ), match .end ()
362+ else :
363+ alias , field = None , name
364+
365+ while i < n and body [i ] in ' \t \r \n ' :
366+ i += 1
367+ rawArgs = ""
368+ if i < n and body [i ] == '(' :
369+ end = _graphql_match (body , i )
370+ rawArgs , i = body [i + 1 :end - 1 ], end
371+
372+ while i < n and body [i ] in ' \t \r \n ' :
373+ i += 1
374+ if i < n and body [i ] == '{' : # skip this field's (possibly nested) selection set
375+ i = _graphql_match (body , i )
376+
377+ selections .append ((alias , field , rawArgs ))
378+ return selections
379+
380+
381+ def _graphql_resolve (query , variables ):
382+ """Minimal GraphQL resolver: parse the query, call the matching resolver for each top-level field,
383+ and return (data_dict_or_None, errors_list). Multiple aliased fields are supported in one request
384+ (alias:field(args){...} ...), so a client can batch independent probes into a single round-trip."""
385+
386+ variables = variables or {}
387+ errors = []
388+ data = {}
389+
390+ op = "query"
391+ for keyword in ("mutation" , "subscription" ):
392+ if query .strip ().startswith (keyword ):
393+ op = keyword
394+ break
395+
396+ start = query .find ('{' )
397+ if start == - 1 :
398+ errors .append ({"message" : "Cannot parse query" , "extensions" : {"code" : "GRAPHQL_PARSE_FAILED" }})
399+ return None , errors
400+
401+ for alias , field , rawArgs in _graphql_selections (query [start + 1 :_graphql_match (query , start ) - 1 ]):
402+ key = alias or field
403+
404+ # Parse arguments
405+ args = {}
406+ for am in re .finditer (r'(\w+)\s*:\s*("(?:[^"\\]|\\.)*"|\$?\w+(?:\.\w+)?)' , rawArgs ):
407+ name , val = am .group (1 ), am .group (2 )
408+ if val .startswith ('$' ):
409+ args [name ] = variables .get (val [1 :], None )
410+ else :
411+ args [name ] = _graphql_arg (val )
412+
413+ try :
414+ if field in ("__typename" , "__schema" ):
415+ data [key ] = op .title ()
416+ elif field == "user" :
417+ data [key ] = _resolver_user (args .get ("username" ))
418+ elif field == "search" :
419+ data [key ] = _resolver_search (args .get ("term" ))
420+ elif field == "login" :
421+ data [key ] = _resolver_login (args .get ("username" ), args .get ("password" ))
422+ elif field == "updateUser" :
423+ data [key ] = _resolver_updateUser (args .get ("id" ), args .get ("email" ))
424+ else :
425+ errors .append ({"message" : "Cannot query field '%s' on type '%s'. Did you mean 'user', 'search', 'login', or 'updateUser'?" % (field , op .title ()),
426+ "extensions" : {"code" : "GRAPHQL_VALIDATION_FAILED" }})
427+ except Exception as ex :
428+ # Leak the backend error through the GraphQL error envelope (as many real servers do
429+ # in development mode) -- this drives error-based detection
430+ errors .append ({"message" : "%s: %s" % (re .search (r"'([^']+)'" , str (type (ex ))).group (1 ), ex ),
431+ "path" : [key ], "extensions" : {"exception" : str (ex )}})
432+
433+ if not data and not errors :
434+ return None , errors
435+ return data , errors
436+
437+
438+ # --- Vulnerable resolvers (direct string concatenation into SQLite) ------------------------
439+
440+ def _resolver_user (username ):
441+ if not username :
442+ return None
443+ with _lock :
444+ _cursor .execute ("SELECT id, name, surname FROM users WHERE name='%s'" % username )
445+ row = _cursor .fetchone ()
446+ return {"id" : row [0 ], "name" : row [1 ], "surname" : row [2 ]} if row else None
447+
448+
449+ def _resolver_search (term ):
450+ with _lock :
451+ _cursor .execute ("SELECT id, name, surname FROM users WHERE name LIKE '%%%s%%'" % (term or "" ))
452+ rows = _cursor .fetchall ()
453+ return [{"id" : r [0 ], "name" : r [1 ], "surname" : r [2 ]} for r in (rows or [])]
454+
455+
456+ def _resolver_login (username , password ):
457+ if not username or not password :
458+ return None
459+ with _lock :
460+ _cursor .execute ("SELECT u.id, u.name, u.surname FROM users u JOIN creds c ON u.id=c.user_id WHERE u.name='%s' AND c.password_hash='%s'" % (username , password ))
461+ row = _cursor .fetchone ()
462+ if row :
463+ return {"token" : "tok_%d_%s" % (row [0 ], row [1 ]), "user" : {"id" : row [0 ], "name" : row [1 ], "surname" : row [2 ]}}
464+ return None # returns null in data (boolean oracle: true=object, false=null)
465+
466+
467+ def _resolver_updateUser (id_ , email ):
468+ with _lock :
469+ _cursor .execute ("UPDATE users SET surname='%s' WHERE id=%s" % (email , id_ ))
470+ _cursor .execute ("SELECT id, name, surname FROM users WHERE id=%s" % id_ )
471+ row = _cursor .fetchone ()
472+ return {"id" : row [0 ], "name" : row [1 ], "surname" : row [2 ]} if row else None
473+
474+
249475class ReqHandler (BaseHTTPRequestHandler ):
250476 def do_REQUEST (self ):
251477 path , query = self .path .split ('?' , 1 ) if '?' in self .path else (self .path , "" )
@@ -339,6 +565,35 @@ def do_REQUEST(self):
339565 self .wfile .write (output .encode (UNICODE_ENCODING ))
340566 return
341567
568+ if self .url == "/graphql" :
569+ self .send_response (OK )
570+ self .send_header ("Content-type" , "application/json; charset=%s" % UNICODE_ENCODING )
571+ self .send_header ("Connection" , "close" )
572+ self .end_headers ()
573+
574+ query = self .params .get ("query" , "" )
575+ variables = self .params .get ("variables" ) or {}
576+
577+ if not isinstance (variables , dict ):
578+ try :
579+ variables = json .loads (str (variables ))
580+ except Exception :
581+ variables = {}
582+
583+ if "__schema" in query :
584+ output = json .dumps (_graphql_introspection ())
585+ else :
586+ data , errors = _graphql_resolve (query , variables )
587+ resp = {}
588+ if errors :
589+ resp ["errors" ] = errors
590+ if data :
591+ resp ["data" ] = data
592+ output = json .dumps (resp , default = str )
593+
594+ self .wfile .write (output .encode (UNICODE_ENCODING ))
595+ return
596+
342597 if self .url == '/' :
343598 if not any (_ in self .params for _ in ("id" , "query" )):
344599 self .send_response (OK )
0 commit comments