@@ -81,7 +81,7 @@ def _allowed_method(self, method: str) -> bool:
8181 return method in spec_mod .ALL_METHODS
8282
8383 def request (self , method : str , path : str , query : dict = None ,
84- headers : dict = None ) -> dict :
84+ headers : dict = None , body : str = None ) -> dict :
8585 from app .core import external_probe
8686
8787 method = (method or "GET" ).upper ()
@@ -100,7 +100,7 @@ def request(self, method: str, path: str, query: dict = None,
100100 sent .update (headers or {})
101101 started = time .monotonic ()
102102 got = external_probe .fetch (url , method = method , headers = sent ,
103- allow_private = self .allow_private )
103+ allow_private = self .allow_private , body = body )
104104 got ["seconds" ] = time .monotonic () - started
105105 got .setdefault ("body" , "" )
106106 return got
@@ -337,6 +337,20 @@ def run_rule(spec: dict, ctx: dict) -> dict:
337337 out ["status" ], out ["detail" ] = "error" , f"{ type (exc ).__name__ } : { exc } " [:200 ]
338338 return out
339339
340+ # A GraphQL rule drives its own probe: it reads the schema, then proves the
341+ # endpoint lets one request carry many aliased operations, which turns a
342+ # single POST into many login attempts. POST with a JSON body, made up
343+ # credentials for an account that cannot exist, so it authenticates nobody
344+ # and changes nothing.
345+ if any (isinstance (s , dict ) and s .get ("graphql" ) for s in spec .get ("probe" ) or []):
346+ try :
347+ out ["findings" ] = _run_graphql (spec , ctx , domains , budget )
348+ except Refused as exc :
349+ out ["status" ], out ["detail" ] = "refused" , str (exc )
350+ except Exception as exc : # noqa: BLE001
351+ out ["status" ], out ["detail" ] = "error" , f"{ type (exc ).__name__ } : { exc } " [:200 ]
352+ return out
353+
340354 client = Client (base_url , domains , spec ["safety" ], budget ,
341355 allow_private = bool (ctx .get ("allow_private" )),
342356 headers = ctx .get ("headers" ))
@@ -634,6 +648,256 @@ def _two_account_evidence(url, resp_a, resp_b, resp_anon) -> str:
634648 return " " .join (bits )[:1100 ]
635649
636650
651+ # Alias labels for the amplification probe. Valid GraphQL names, ours so they
652+ # cannot collide with a real field.
653+ _GQL_LABELS = ["gcr0" , "gcr1" , "gcr2" ]
654+
655+
656+ def _gql_post (client : Client , path : str , query : str ) -> dict :
657+ """One GraphQL request: POST the query as JSON, the way a client would."""
658+ return client .request (
659+ "POST" , path , body = json .dumps ({"query" : query }),
660+ headers = {"Content-Type" : "application/json" , "Accept" : "application/json" })
661+
662+
663+ def _gql_json (resp : dict ):
664+ try :
665+ return json .loads ((resp or {}).get ("body" ) or "" )
666+ except Exception : # noqa: BLE001 - a non JSON answer is just not GraphQL
667+ return None
668+
669+
670+ def _gql_is_graphql (resp : dict ) -> bool :
671+ """True when the endpoint answers like GraphQL, so a non GraphQL 200 that
672+ happens to sit at /graphql never leads anywhere."""
673+ data = _gql_json (resp )
674+ if isinstance (data , dict ) and ("data" in data or "errors" in data ):
675+ return True
676+ body = ((resp or {}).get ("body" ) or "" ).lower ()
677+ return ("must provide query" in body or "syntax error" in body
678+ or "graphql" in body and "query" in body )
679+
680+
681+ def _gql_alias_hits (resp : dict , labels ) -> int :
682+ """How many of the aliased operations the server actually ran.
683+
684+ An executed alias shows up either as a key in data or, for a failed login,
685+ as the path of its own error. Counting these is how the amplification is
686+ proved: three in, three run, so one request made three attempts.
687+ """
688+ data = _gql_json (resp )
689+ body = (resp or {}).get ("body" ) or ""
690+ if not isinstance (data , dict ):
691+ return sum (1 for lbl in labels if f'"{ lbl } "' in body )
692+ d = data .get ("data" ) if isinstance (data .get ("data" ), dict ) else {}
693+ errs = data .get ("errors" ) if isinstance (data .get ("errors" ), list ) else []
694+ hits = 0
695+ for lbl in labels :
696+ if lbl in d :
697+ hits += 1
698+ elif any (isinstance (e , dict ) and lbl in (e .get ("path" ) or []) for e in errs ):
699+ hits += 1
700+ return hits
701+
702+
703+ def _gql_schema_names (client : Client , path : str ):
704+ """The query and mutation field names the schema advertises, when
705+ introspection is on. Empty lists when it is off."""
706+ q = "{__schema{queryType{fields{name}} mutationType{fields{name}}}}"
707+ data = _gql_json (_gql_post (client , path , q ))
708+ queries , mutations = [], []
709+ try :
710+ sch = data ["data" ]["__schema" ]
711+ queries = [f ["name" ] for f in (sch .get ("queryType" ) or {}).get ("fields" ) or []]
712+ mutations = [f ["name" ] for f in (sch .get ("mutationType" ) or {}).get ("fields" ) or []]
713+ except Exception : # noqa: BLE001 - introspection off or a different shape
714+ pass
715+ return queries , mutations
716+
717+
718+ def _run_graphql (spec : dict , ctx : dict , domains , budget : Budget ) -> list :
719+ """Read the schema, then prove request amplification. -> findings.
720+
721+ Non destructive: it reads the schema, it tries a login mutation with made
722+ up credentials for an account that cannot exist, and it asks an anonymous
723+ account-exists oracle about a made up address. Nothing is written and no
724+ real account is touched. The finding fires only when the server runs every
725+ aliased operation in one request, which is the amplification itself.
726+ """
727+ base_url = ctx .get ("base_url" ) or ""
728+ allow_private = bool (ctx .get ("allow_private" ))
729+ step = next ((s .get ("graphql" ) for s in spec .get ("probe" ) or []
730+ if isinstance (s , dict ) and s .get ("graphql" )), {}) or {}
731+ path = str (step .get ("path" ) or "/graphql" )
732+ client = Client (base_url , domains , spec .get ("safety" , "neutral" ), budget ,
733+ allow_private , ctx .get ("headers" ))
734+ retest = max (1 , int ((spec .get ("confirm" ) or {}).get ("retest" ) or 1 ))
735+
736+ # Is this really GraphQL, and is introspection on?
737+ intro = _gql_post (client , path , "{__schema{queryType{name}}}" )
738+ if not _gql_is_graphql (intro ):
739+ return []
740+ body0 = intro .get ("body" ) or ""
741+ introspection_on = (intro .get ("status" ) == 200 and "queryType" in body0
742+ and '"errors"' not in body0 )
743+
744+ queries , mutations = ([], [])
745+ if introspection_on :
746+ queries , mutations = _gql_schema_names (client , path )
747+ has_token = "generateCustomerToken" in mutations
748+ has_oracle = "isEmailAvailable" in queries
749+
750+ # Does the account-exists oracle answer an anonymous caller? Harmless: a
751+ # made up address at a reserved domain no account can own. Self gating: if
752+ # the field does not exist the server just says so, and this stays false.
753+ probe_email = f"gcr-{ secrets .token_hex (4 )} @example.invalid"
754+ oq = _gql_post (client , path ,
755+ '{gcr0:isEmailAvailable(email:"%s"){is_email_available}}' % probe_email )
756+ oracle_live = (oq .get ("status" ) == 200
757+ and "is_email_available" in (oq .get ("body" ) or "" ))
758+ has_oracle = has_oracle or oracle_live
759+
760+ def amp_confirmed (build ):
761+ resp = None
762+ for _ in range (retest ):
763+ resp = build ()
764+ if (resp .get ("status" ) in (200 , 400 )
765+ and _gql_alias_hits (resp , _GQL_LABELS ) == len (_GQL_LABELS )):
766+ continue
767+ return False , resp
768+ return True , resp
769+
770+ def login_query ():
771+ parts = []
772+ for lbl in _GQL_LABELS :
773+ em = f"gcr-{ secrets .token_hex (5 )} @example.invalid"
774+ pw = f"gcr-not-a-real-password-{ secrets .token_hex (5 )} "
775+ parts .append ('%s: generateCustomerToken(email:"%s", password:"%s"){token}'
776+ % (lbl , em , pw ))
777+ return _gql_post (client , path , "mutation{ %s }" % " " .join (parts ))
778+
779+ def generic_query ():
780+ parts = ["%s: __typename" % lbl for lbl in _GQL_LABELS ]
781+ return _gql_post (client , path , "{ %s }" % " " .join (parts ))
782+
783+ # The login mutation is the serious case: a single request becomes many
784+ # password guesses. Self gating: if the field does not exist the aliases do
785+ # not run and nothing fires. Otherwise fall back to proving the generic
786+ # aliasing primitive, which still amplifies any field.
787+ login_amp , login_resp = amp_confirmed (login_query )
788+ generic_amp , generic_resp = (False , None )
789+ if not login_amp :
790+ generic_amp , generic_resp = amp_confirmed (generic_query )
791+
792+ if login_amp :
793+ severity , amp_resp = "high" , login_resp
794+ elif generic_amp and introspection_on :
795+ severity , amp_resp = "medium" , generic_resp
796+ else :
797+ return []
798+
799+ f = _finding (spec , client , amp_resp or intro , path )
800+ f ["severity" ] = severity
801+ if login_amp :
802+ f ["title" ] = f"GraphQL turns one request into many login attempts ({ path } )"
803+ f ["evidence" ] = _graphql_evidence (path , introspection_on , mutations , has_token ,
804+ has_oracle , oracle_live , login_amp )
805+ f ["reproduce" ] = _graphql_reproduce (base_url , path , login_amp , has_oracle )
806+ return [f ]
807+
808+
809+ def _graphql_evidence (path , introspection_on , mutations , has_token , has_oracle ,
810+ oracle_live , login_amp ) -> str :
811+ """Spell out the chain the scan actually walked, strongest fact first, then
812+ the escalation table so the severity is justified by what was seen."""
813+ bits = []
814+ if introspection_on :
815+ bits .append (f"The endpoint at { path } is GraphQL and introspection is on, "
816+ f"so anyone can read the whole schema: every type, field and "
817+ f"mutation." )
818+ if mutations :
819+ count = f"{ len (mutations )} mutation" + ("" if len (mutations ) == 1 else "s" )
820+ named = ", including generateCustomerToken (the login mutation)" if has_token else ""
821+ bits .append (f"It advertises { count } { named } ." )
822+ else :
823+ bits .append (f"The endpoint at { path } is GraphQL." )
824+ if login_amp :
825+ bits .append ("Amplification confirmed: one POST carried three aliased "
826+ "generateCustomerToken operations and the server ran all "
827+ "three in that single request, returning three separate "
828+ "results. So one request is three login attempts, and at "
829+ "scale the per request login rate limit and WAF counting are "
830+ "bypassed." )
831+ else :
832+ bits .append ("Amplification confirmed: one request carried three aliased "
833+ "operations and the server ran all three, so a single request "
834+ "can be multiplied into many. No login mutation was confirmed "
835+ "here, so this is the amplification primitive on its own." )
836+ if oracle_live :
837+ bits .append ("The isEmailAvailable oracle answered an anonymous request, "
838+ "so which customer emails exist can be enumerated without "
839+ "signing in, then fed into the amplified guessing." )
840+ elif has_oracle :
841+ bits .append ("The schema also exposes isEmailAvailable, an account exists "
842+ "oracle." )
843+ bits .append ("" )
844+ bits .append ("Why this is serious, each primitive alone then chained:" )
845+ bits .append (" Introspection on (schema readable) info" )
846+ if has_oracle :
847+ bits .append (" isEmailAvailable account enumeration medium" )
848+ if login_amp :
849+ bits .append (" Aliasing bypasses login rate limiting high" )
850+ bits .append (" Chained, on a site open to the world account takeover, customer PII" )
851+ else :
852+ bits .append (" Aliasing multiplies any field per request medium (brute force, resource exhaustion)" )
853+ bits .append ("" )
854+ bits .append ("Validate before acting: if this endpoint is not reachable from "
855+ "the internet, or the amplified fields are rate limited at the "
856+ "edge in production, re-categorise it or mark it a false "
857+ "positive. Staging hosts sometimes relax these." )
858+ return "\n " .join (bits )[:1800 ]
859+
860+
861+ def _graphql_reproduce (base_url , path , login_amp , has_oracle ) -> str :
862+ """The curls a developer runs to see it, strongest first. Made up
863+ credentials and a reserved address, so running them attacks nobody."""
864+ from urllib .parse import urljoin
865+ url = urljoin (base_url .rstrip ("/" ) + "/" , path .lstrip ("/" ))
866+ steps = ["Three requests, strongest first. Run them from outside the "
867+ "company network. The credentials below are made up on purpose." ,
868+ "" ,
869+ "1) Dump the write surface (every mutation an anonymous caller can reach):" ,
870+ f"curl -s '{ url } ' -H 'Content-Type: application/json' \\ " ,
871+ " -d '{\" query\" :\" { __schema { mutationType { fields { name } } } }\" }' "
872+ "| python3 -m json.tool" ,
873+ "" ]
874+ if login_amp :
875+ steps += [
876+ "2) Prove amplification: one POST is three login attempts. All three "
877+ "run, so a single request becomes N password guesses the login rate "
878+ "limit never sees (keep it at three, this is proof, not an attack):" ,
879+ f"curl -s '{ url } ' -H 'Content-Type: application/json' \\ " ,
880+ " -d '{\" query\" :\" mutation { a: generateCustomerToken(email:\\ \" poc@example.invalid\\ \" ,password:\\ \" wrong1\\ \" ){token} "
881+ "b: generateCustomerToken(email:\\ \" poc@example.invalid\\ \" ,password:\\ \" wrong2\\ \" ){token} "
882+ "c: generateCustomerToken(email:\\ \" poc@example.invalid\\ \" ,password:\\ \" wrong3\\ \" ){token} }\" }' "
883+ "| python3 -m json.tool" ]
884+ else :
885+ steps += [
886+ "2) Prove amplification: one POST carries three aliased operations and "
887+ "all three run, so any field can be multiplied in a single request:" ,
888+ f"curl -s '{ url } ' -H 'Content-Type: application/json' \\ " ,
889+ " -d '{\" query\" :\" { a: __typename b: __typename c: __typename }\" }' "
890+ "| python3 -m json.tool" ]
891+ if has_oracle :
892+ steps += [
893+ "" ,
894+ "3) Enumerate which accounts exist (anonymous), to aim step 2:" ,
895+ f"curl -s '{ url } ' -H 'Content-Type: application/json' \\ " ,
896+ " -d '{\" query\" :\" { isEmailAvailable(email:\\ \" known@customer.com\\ \" ){ is_email_available } }\" }' "
897+ "| python3 -m json.tool" ]
898+ return "\n " .join (steps )
899+
900+
637901def _run_probe (spec : dict , client : Client , route : str , oob_value : str ) -> dict :
638902 """Send the probe steps; the last response is what the differential reads.
639903
0 commit comments