Skip to content

Commit fc8a4f5

Browse files
committed
Stop the Rapid7 integration switching itself off, and spot GraphQL request amplification
Two fixes. The general settings page was turning the Rapid7 integration off by itself. That integration is set up on its own tab, so the general settings form does not carry its fields. The save routine wrote them anyway, which read the absent on/off box as off and blanked the stored key every time the page was saved for anything else. The save now leaves those settings alone unless the form actually submitted them, so the only place that changes them is their own tab. New premium check for a GraphQL endpoint. It reads the schema, then proves the endpoint runs many aliased operations in one request: a single POST becomes many login attempts, so the per request login limit and the edge counting are bypassed, and an anonymous account-exists query lets valid accounts be enumerated to aim the guessing. It only fires when the server actually runs every aliased operation, and the finding carries the escalation and the exact commands to see it again. Credentials used in the probe are made up, for an account that cannot exist, so it authenticates nobody and writes nothing.
1 parent c9ae353 commit fc8a4f5

4 files changed

Lines changed: 333 additions & 21 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"id": "access-control-graphql-batch-amplification",
3+
"class": "access-control",
4+
"safety": "neutral",
5+
"neutral_justification": "Sends GraphQL only: an introspection read of the schema, a login mutation with made up credentials for an account that cannot exist (at a reserved example.invalid address), and an anonymous account-exists query about a made up address. It authenticates nobody, writes nothing, and touches no real account.",
6+
"severity_floor": "high",
7+
"title": "GraphQL endpoint allows request amplification (/graphql)",
8+
"summary": "The endpoint lets one request carry many aliased operations, so a single POST becomes many login attempts. That bypasses per request login rate limiting and WAF counting, and combined with the account-exists oracle it makes credential guessing practical from the open internet.",
9+
"needs": [],
10+
"probe": [
11+
{
12+
"graphql": {
13+
"path": "/graphql"
14+
}
15+
}
16+
],
17+
"confirm": {
18+
"control": "A single, un-aliased query, so the normal single-operation answer is known. The handler fires only when the server runs every aliased operation in one request, which a single query cannot show.",
19+
"differential": "status_in(200, 400)",
20+
"retest": 2
21+
},
22+
"reproduce": "Dump the mutations with an introspection POST, then send one POST that aliases generateCustomerToken three times with made up credentials and confirm all three run in the single response.",
23+
"remediate": "Turn introspection off in production, cap query complexity and the number of aliased operations per request (this kills the amplification), rate limit generateCustomerToken and isEmailAvailable by IP at the edge, and make isEmailAvailable return a generic answer so it stops being an account-exists oracle.",
24+
"references": [],
25+
"mappings": {
26+
"cwe": "CWE-770",
27+
"owasp": "API4:2023"
28+
},
29+
"rulesets": [
30+
"graphql"
31+
]
32+
}

‎app/core/dast_checks/engine.py‎

Lines changed: 266 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
637901
def _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

‎app/core/external_probe.py‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -472,14 +472,19 @@ def _read_cert(info: dict, der: bytes) -> None:
472472

473473
def fetch(url: str, method: str = "GET", headers: dict = None,
474474
follow: bool = True, allow_private: bool = False,
475-
body_limit: int = BODY_LIMIT, timeout: float = HTTP_TIMEOUT) -> dict:
475+
body_limit: int = BODY_LIMIT, timeout: float = HTTP_TIMEOUT,
476+
body: str = None) -> dict:
476477
"""One request, the way a browser would make it.
477478
478479
-> {status, url, final_url, headers, set_cookies, body, redirects, error}
479480
480481
Redirects are followed by hand, at most five, and every hop is checked
481482
against private addresses unless those are allowed, so a site cannot
482483
bounce this server into the network it sits in.
484+
485+
body is the raw request content for a POST, such as a JSON GraphQL query.
486+
It is dropped if a redirect turns the request into a GET, so a body never
487+
rides along to a different method or place.
483488
"""
484489
import httpx
485490
from urllib.parse import urljoin, urlparse
@@ -490,6 +495,7 @@ def fetch(url: str, method: str = "GET", headers: dict = None,
490495
sent = dict(headers or {})
491496
sent.setdefault("User-Agent", user_agent())
492497
sent.setdefault("Accept", "text/html,application/xhtml+xml,*/*;q=0.8")
498+
content = body.encode("utf-8") if isinstance(body, str) else body
493499
try:
494500
proxy = current_proxy()
495501
with httpx.Client(verify=False, timeout=timeout, follow_redirects=False,
@@ -499,7 +505,8 @@ def fetch(url: str, method: str = "GET", headers: dict = None,
499505
if not allow_private and not _host_is_public(host):
500506
out["error"] = f"{host} resolves to a private address, not followed"
501507
return out
502-
with client.stream(method, current, headers=sent) as resp:
508+
with client.stream(method, current, headers=sent,
509+
content=content) as resp:
503510
body = b""
504511
if method != "HEAD":
505512
for chunk in resp.iter_bytes():
@@ -524,6 +531,7 @@ def fetch(url: str, method: str = "GET", headers: dict = None,
524531
out["redirects"].append(current)
525532
if method == "POST":
526533
method = "GET"
534+
content = None
527535
continue
528536
return out
529537
except Exception as exc: # noqa: BLE001 - no answer is a result

0 commit comments

Comments
 (0)