Adding support for GraphQL (--graphql)

This commit is contained in:
Miroslav Štampar 2026-06-27 19:23:30 +02:00
parent 2893fd5c4d
commit f6912fc921
11 changed files with 2207 additions and 8 deletions

View file

@ -160,10 +160,10 @@ ca86d61d3349ed2d94a6b164d4648cff9701199b5e32378c3f40fca0f517b128 extra/shutils/
df768bcb9838dc6c46dab9b4a877056cb4742bd6cfaaf438c4a3712c5cc0d264 extra/shutils/recloak.sh
1972990a67caf2d0231eacf60e211acf545d9d0beeb3c145a49ba33d5d491b3f extra/shutils/strip.sh
1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 extra/vulnserver/__init__.py
43214ecb0101bce72eb243c91b90db34693ebfd485d6c111a4ae22591ff7800b extra/vulnserver/vulnserver.py
faaaa586baa4df245b8780a1a808ebf07e3027ce4245ded3274d908c49e1eecd extra/vulnserver/vulnserver.py
a2bf70d7f87c3a4e0675c0bad54119a4e04efa6ea2730a8338d5aebcd995630e lib/controller/action.py
0c6433b289094d37f295238699042a34a6ab950bb3d11f74fe9a83d30bb7f4bd lib/controller/checks.py
ea0fdf6bcda59aae4d093bada965654a0cd940227c2dbdf62b6ded79baa8dfad lib/controller/controller.py
284b5b056f048e5951c43605965f6758cb9cefa54ca30d818b2c1d1c6713fb91 lib/controller/checks.py
b1e89bff221cc907f5033bae941bf7929de9490f5dcdf2747cba676acd2da95b lib/controller/controller.py
d69e84f1648cdb907f5d2dd454f03874a4613752b07867510145d51d84b3c56f lib/controller/handler.py
1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/controller/__init__.py
9c5764c92ce536d1f0f96200359ee5ef1f37f9128769bf990cb77f1d1f8e17b1 lib/core/agent.py
@ -181,7 +181,7 @@ f8de57606325456928e46ae2896f5f8bbec9ad18b1c644b492a566fa992216f6 lib/core/decor
5387168e5dfedd94ae22af7bb255f27d6baaca50b24179c6b98f4f325f5cc7b4 lib/core/exception.py
1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/core/__init__.py
914a13ee21fd610a6153a37cbe50830fcbd1324c7ebc1e7fc206d5e598b0f7ad lib/core/log.py
056930fba3cf9827f97d280bc38ac785c93108eb84c922f5f39723bb04dcf403 lib/core/optiondict.py
1b03686e1aa916ccad3cd86b8e4e6ea4baca5e30e05bf86a56f8df8dd4f44ba6 lib/core/optiondict.py
4e7f2ad3d2866093aa195616a0e93de1687406edc0b9038fbfa76bf1c9c174b2 lib/core/option.py
ccc4a717e887652b1fcce073d9409d9c59a3b28548c703a9e453d15845f90cd7 lib/core/patch.py
49c0fa7e3814dfda610d665ee02b12df299b28bc0b6773815b4395514ddf8dec lib/core/profiling.py
@ -189,18 +189,18 @@ ccc4a717e887652b1fcce073d9409d9c59a3b28548c703a9e453d15845f90cd7 lib/core/patch
9bf174058f15d14e24e94f9aaf42df045119d3617c6c54bd2f3af79b462f331d lib/core/replication.py
0b8c38a01bb01f843d94a6c5f2075ee47520d0c4aa799cecea9c3e2c5a4a23a6 lib/core/revision.py
888daba83fd4a34e9503fe21f01fef4cc730e5cde871b1d40e15d4cbc847d56c lib/core/session.py
ca14e55b4d49a9b9f4e547180828030e4fcc51176dc9036879dbdae05919dd02 lib/core/settings.py
7032c06dba29cfc35330e022823b778aa87849d5e92a33f4daff2a364d0c9ecd lib/core/settings.py
c7804223319e18eb0b8e2cbf0a8b6896d1cefb7b0b1a2e9f1cf826a8a3b56750 lib/core/shell.py
a2e98a94b231432736d6b304fc75525c8b5fdb4768c418387c5b4c1a610dad64 lib/core/subprocessng.py
19f1e3c5e3ba703d28d510cd7a9ab8284d5fbe9df5ce7e77c86e5931571364b7 lib/core/target.py
e453904a50372216b09146ad9f11cdced2323c10f49c3d866238cc044dcb2cce lib/core/testing.py
b63a8c4caed56796010e9b438ae6b4c398d4c4ed48d74b0a1a270302e0ce87ca lib/core/testing.py
95656c44bab1771f4808030dd6a17eae5b129cb1234443f00b19695c7b712b86 lib/core/threads.py
b9aacb840310173202f79c2ba125b0243003ee6b44c92eca50424f2bdfc83c02 lib/core/unescaper.py
53e396902cb2546eaa09e77073fcba8be8827ee9ce055cfc899e81b0e6ad4d6d lib/core/update.py
2400e465fa4d13e4c32795910878c71ff212e4361b46428d57ce43983f5e997c lib/core/wordlist.py
1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/__init__.py
54bfd31ebded3ffa5848df1c644f196eb704116517c7a3d860b5d081e984d821 lib/parse/banner.py
223badcfd102cdf3313411b63d09b6c59599d58dfc40d27409b1bfa2efc1aa8f lib/parse/cmdline.py
c515041ee2d50aded9afa371de47c3c44c81b30546fb1f6f170b2169ae5e64b4 lib/parse/cmdline.py
02d82e4069bd98c52755417f8b8e306d79945672656ac24f1a45e7a6eff4b158 lib/parse/configfile.py
c5b258be7485089fac9d9cd179960e774fbd85e62836dc67cce76cc028bb6aeb lib/parse/handler.py
5c9a9caee948843d5537745640cc7b98d70a0412cc0949f59d4ebe8b2907c06c lib/parse/headers.py
@ -239,6 +239,8 @@ a66a4b9df6207dce722c9b71d290ea426723cb4b697b416065dc7dd5db96fe8e lib/techniques
74ca78082dcd20b3faf07cc944cd65ea552996df40e6fb58d0a011b262528456 lib/techniques/dns/use.py
1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/techniques/error/__init__.py
5bbef46c16e34fd80e3f9f0e9aa255ce2e39be0d0e57479e25890b041c7efc7d lib/techniques/error/use.py
1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/techniques/graphql/__init__.py
a1c5ec208843eb93e0fab40daac090aa3bf914a7dd0afb0f7c55c2db4db8d72b lib/techniques/graphql/inject.py
1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/techniques/__init__.py
44401cad3e39ae9fb899ed5d0e2fdd0879561de05c3117f17f3b0db54f4e3724 lib/techniques/nosql/__init__.py
d62b28bf9f1544e65a1017994402f484166f4d64a1efb724351b15e27b851990 lib/techniques/nosql/inject.py
@ -594,6 +596,7 @@ ed5a0e453b811dc3dcc5ca28e14a9d7552aacaa7e316e1bca1b042dc5939e204 tests/test_dns
9cd5841349bc4db818658d12184929a96f7f279eff1f53ad18a54dbefbd6b276 tests/test_dump_jsonl.py
2bbe4b01f79992cfa8884651fc0a28dbd0e3abb0cbea9eb7eadf1f98ca3c3420 tests/test_encoding.py
bb6991260a994fcbe79e05febaa34affd5631d02299fbc626820addd5f6ea4f4 tests/test_error_engine.py
4a5f9392b7fec7b40c4d865b83306b58b76f3423cebc2876e6e75fb91b037202 tests/test_graphql.py
8105de9978fe286a29f6b635a58db1e9998d86e8dded54d7efdfb9d52a121094 tests/test_hashdb.py
c04e8358fb6df45f69f2f26435c971acde280535bf304e84d30cf2681158c6a7 tests/test_hash.py
d539d0ae758b5bb91e314ab82ab4fe03d6fb2f8b377d16aefa6d7d1d77a7d5a9 tests/test_identifiers_output.py

View file

@ -246,6 +246,232 @@ def waf_score(value, ua=None, level=0):
retVal += WAF_SCANNER_UA_WEIGHT
return retVal
# --- GraphQL endpoint (vulnerable Apollo-style, backed by the same SQLite database) ----------
# Hard-coded introspection response matching the schema below. Every GraphQL tool (including
# sqlmap's --graphql engine) uses this to discover fields, arguments, and types.
def _graphql_introspection():
return {
"data": {
"__schema": {
"queryType": {"name": "Query"},
"mutationType": {"name": "Mutation"},
"subscriptionType": None,
"directives": [],
"types": [
{"kind": "OBJECT", "name": "Query", "fields": [
{"name": "user", "args": [
{"name": "username", "defaultValue": None, "type": {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "String", "ofType": None}}}
], "type": {"kind": "OBJECT", "name": "User", "ofType": None}},
{"name": "search", "args": [
{"name": "term", "defaultValue": None, "type": {"kind": "SCALAR", "name": "String", "ofType": None}}
], "type": {"kind": "LIST", "name": None, "ofType": {"kind": "OBJECT", "name": "User", "ofType": None}}},
{"name": "login", "args": [
{"name": "username", "defaultValue": None, "type": {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "String", "ofType": None}}},
{"name": "password", "defaultValue": None, "type": {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "String", "ofType": None}}}
], "type": {"kind": "OBJECT", "name": "AuthPayload", "ofType": None}},
], "inputFields": None, "enumValues": None},
{"kind": "OBJECT", "name": "Mutation", "fields": [
{"name": "updateUser", "args": [
{"name": "id", "defaultValue": None, "type": {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "Int", "ofType": None}}},
{"name": "email", "defaultValue": None, "type": {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "String", "ofType": None}}}
], "type": {"kind": "OBJECT", "name": "User", "ofType": None}},
], "inputFields": None, "enumValues": None},
{"kind": "INPUT_OBJECT", "name": "UpdateUserInput", "inputFields": [
{"name": "id", "defaultValue": None, "type": {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "Int", "ofType": None}}},
{"name": "email", "defaultValue": None, "type": {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "String", "ofType": None}}}
]},
{"kind": "SCALAR", "name": "Int"},
{"kind": "SCALAR", "name": "String"},
{"kind": "SCALAR", "name": "Boolean"},
{"kind": "SCALAR", "name": "Float"},
{"kind": "SCALAR", "name": "ID"},
{"kind": "OBJECT", "name": "User", "fields": [
{"name": "id", "args": [], "type": {"kind": "SCALAR", "name": "Int", "ofType": None}},
{"name": "name", "args": [], "type": {"kind": "SCALAR", "name": "String", "ofType": None}},
{"name": "surname", "args": [], "type": {"kind": "SCALAR", "name": "String", "ofType": None}},
], "inputFields": None, "enumValues": None},
{"kind": "OBJECT", "name": "AuthPayload", "fields": [
{"name": "token", "args": [], "type": {"kind": "SCALAR", "name": "String", "ofType": None}},
{"name": "user", "args": [], "type": {"kind": "OBJECT", "name": "User", "ofType": None}},
], "inputFields": None, "enumValues": None},
]
}
}
}
def _graphql_arg(raw):
"""Parse a single GraphQL argument value: strip quotes from strings, keep numbers as-is"""
raw = raw.strip()
if raw.startswith('"') and raw.endswith('"'):
return raw[1:-1].replace('\\"', '"')
return raw
def _graphql_match(text, start):
"""Index just past the bracket matching the one at text[start] ('(' or '{'), skipping over
double-quoted strings so brackets inside argument literals (e.g. an injected SQL payload) and
nested selection sets do not throw off the balance."""
pairs = {'(': ')', '{': '}'}
opener, closer = text[start], pairs[text[start]]
depth, i, n = 0, start, len(text)
while i < n:
char = text[i]
if char == '"':
i += 1
while i < n and text[i] != '"':
i += 2 if text[i] == '\\' else 1
elif char == opener:
depth += 1
elif char == closer:
depth -= 1
if depth == 0:
return i + 1
i += 1
return n
def _graphql_selections(body):
"""Split a selection set into its top-level (alias, field, rawArgs) fields, tolerating aliasing,
argument literals carrying brackets/quotes, and nested selection sets (which are skipped over)."""
identifier = re.compile(r'[A-Za-z_]\w*')
selections, i, n = [], 0, len(body)
while i < n:
while i < n and body[i] in ' \t\r\n,':
i += 1
match = identifier.match(body, i)
if not match:
i += 1
continue
name, i = match.group(0), match.end()
j = i
while j < n and body[j] in ' \t\r\n':
j += 1
if j < n and body[j] == ':': # 'name' was an alias; the real field follows
j += 1
while j < n and body[j] in ' \t\r\n':
j += 1
match = identifier.match(body, j)
if not match:
continue
alias, field, i = name, match.group(0), match.end()
else:
alias, field = None, name
while i < n and body[i] in ' \t\r\n':
i += 1
rawArgs = ""
if i < n and body[i] == '(':
end = _graphql_match(body, i)
rawArgs, i = body[i + 1:end - 1], end
while i < n and body[i] in ' \t\r\n':
i += 1
if i < n and body[i] == '{': # skip this field's (possibly nested) selection set
i = _graphql_match(body, i)
selections.append((alias, field, rawArgs))
return selections
def _graphql_resolve(query, variables):
"""Minimal GraphQL resolver: parse the query, call the matching resolver for each top-level field,
and return (data_dict_or_None, errors_list). Multiple aliased fields are supported in one request
(alias:field(args){...} ...), so a client can batch independent probes into a single round-trip."""
variables = variables or {}
errors = []
data = {}
op = "query"
for keyword in ("mutation", "subscription"):
if query.strip().startswith(keyword):
op = keyword
break
start = query.find('{')
if start == -1:
errors.append({"message": "Cannot parse query", "extensions": {"code": "GRAPHQL_PARSE_FAILED"}})
return None, errors
for alias, field, rawArgs in _graphql_selections(query[start + 1:_graphql_match(query, start) - 1]):
key = alias or field
# Parse arguments
args = {}
for am in re.finditer(r'(\w+)\s*:\s*("(?:[^"\\]|\\.)*"|\$?\w+(?:\.\w+)?)', rawArgs):
name, val = am.group(1), am.group(2)
if val.startswith('$'):
args[name] = variables.get(val[1:], None)
else:
args[name] = _graphql_arg(val)
try:
if field in ("__typename", "__schema"):
data[key] = op.title()
elif field == "user":
data[key] = _resolver_user(args.get("username"))
elif field == "search":
data[key] = _resolver_search(args.get("term"))
elif field == "login":
data[key] = _resolver_login(args.get("username"), args.get("password"))
elif field == "updateUser":
data[key] = _resolver_updateUser(args.get("id"), args.get("email"))
else:
errors.append({"message": "Cannot query field '%s' on type '%s'. Did you mean 'user', 'search', 'login', or 'updateUser'?" % (field, op.title()),
"extensions": {"code": "GRAPHQL_VALIDATION_FAILED"}})
except Exception as ex:
# Leak the backend error through the GraphQL error envelope (as many real servers do
# in development mode) -- this drives error-based detection
errors.append({"message": "%s: %s" % (re.search(r"'([^']+)'", str(type(ex))).group(1), ex),
"path": [key], "extensions": {"exception": str(ex)}})
if not data and not errors:
return None, errors
return data, errors
# --- Vulnerable resolvers (direct string concatenation into SQLite) ------------------------
def _resolver_user(username):
if not username:
return None
with _lock:
_cursor.execute("SELECT id, name, surname FROM users WHERE name='%s'" % username)
row = _cursor.fetchone()
return {"id": row[0], "name": row[1], "surname": row[2]} if row else None
def _resolver_search(term):
with _lock:
_cursor.execute("SELECT id, name, surname FROM users WHERE name LIKE '%%%s%%'" % (term or ""))
rows = _cursor.fetchall()
return [{"id": r[0], "name": r[1], "surname": r[2]} for r in (rows or [])]
def _resolver_login(username, password):
if not username or not password:
return None
with _lock:
_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))
row = _cursor.fetchone()
if row:
return {"token": "tok_%d_%s" % (row[0], row[1]), "user": {"id": row[0], "name": row[1], "surname": row[2]}}
return None # returns null in data (boolean oracle: true=object, false=null)
def _resolver_updateUser(id_, email):
with _lock:
_cursor.execute("UPDATE users SET surname='%s' WHERE id=%s" % (email, id_))
_cursor.execute("SELECT id, name, surname FROM users WHERE id=%s" % id_)
row = _cursor.fetchone()
return {"id": row[0], "name": row[1], "surname": row[2]} if row else None
class ReqHandler(BaseHTTPRequestHandler):
def do_REQUEST(self):
path, query = self.path.split('?', 1) if '?' in self.path else (self.path, "")
@ -339,6 +565,35 @@ class ReqHandler(BaseHTTPRequestHandler):
self.wfile.write(output.encode(UNICODE_ENCODING))
return
if self.url == "/graphql":
self.send_response(OK)
self.send_header("Content-type", "application/json; charset=%s" % UNICODE_ENCODING)
self.send_header("Connection", "close")
self.end_headers()
query = self.params.get("query", "")
variables = self.params.get("variables") or {}
if not isinstance(variables, dict):
try:
variables = json.loads(str(variables))
except Exception:
variables = {}
if "__schema" in query:
output = json.dumps(_graphql_introspection())
else:
data, errors = _graphql_resolve(query, variables)
resp = {}
if errors:
resp["errors"] = errors
if data:
resp["data"] = data
output = json.dumps(resp, default=str)
self.wfile.write(output.encode(UNICODE_ENCODING))
return
if self.url == '/':
if not any(_ in self.params for _ in ("id", "query")):
self.send_response(OK)

View file

@ -79,6 +79,7 @@ from lib.core.settings import DEFAULT_GET_POST_DELIMITER
from lib.core.settings import DUMMY_NON_SQLI_CHECK_APPENDIX
from lib.core.settings import FI_ERROR_REGEX
from lib.core.settings import FORMAT_EXCEPTION_STRINGS
from lib.core.settings import GRAPHQL_ERROR_REGEX
from lib.core.settings import HEURISTIC_CHECK_ALPHABET
from lib.core.settings import INFERENCE_EQUALS_CHAR
from lib.core.settings import IPS_WAF_CHECK_PAYLOAD
@ -1178,6 +1179,13 @@ def heuristicCheckSqlInjection(place, parameter):
if conf.beep:
beep()
if not conf.graphql and re.search(GRAPHQL_ERROR_REGEX, page or ""):
infoMsg = "heuristic (GraphQL) test shows that %sparameter '%s' appears to be a GraphQL endpoint (rerun with switch '--graphql')" % ("%s " % paramType if paramType != parameter else "", parameter)
logger.info(infoMsg)
if conf.beep:
beep()
kb.disableHtmlDecoding = False
kb.heuristicMode = False

View file

@ -504,8 +504,21 @@ def start():
infoMsg = "testing URL '%s'" % targetUrl
logger.info(infoMsg)
if conf.graphql and PLACE.GET not in conf.parameters:
# graphqlScan() is self-contained and operates on the GraphQL
# document, not on HTTP parameters. A dummy GET parameter keeps
# _setRequestParams() from appending the URI injection marker ('*')
# to a bare endpoint URL (which would break detection under
# '--batch'); it is discarded by graphqlScan() on entry.
conf.parameters[PLACE.GET] = "x"
setupTargetEnv()
if conf.graphql:
from lib.techniques.graphql.inject import graphqlScan
graphqlScan()
continue
if not checkConnection(suppressOutput=conf.forms):
continue

View file

@ -119,6 +119,7 @@ optDict = {
"Techniques": {
"technique": "string",
"nosql": "boolean",
"graphql": "boolean",
"timeSec": "integer",
"uCols": "string",
"uChar": "string",

View file

@ -20,7 +20,7 @@ from lib.core.enums import OS
from thirdparty import six
# sqlmap version (<major>.<minor>.<month>.<monthly commit>)
VERSION = "1.10.6.160"
VERSION = "1.10.6.161"
TYPE = "dev" if VERSION.count('.') > 2 and VERSION.split('.')[-1] != '0' else "stable"
TYPE_COLORS = {"dev": 33, "stable": 90, "pip": 34}
VERSION_STRING = "sqlmap/%s#%s" % ('.'.join(VERSION.split('.')[:-1]) if VERSION.count('.') > 2 and VERSION.split('.')[-1] == '0' else VERSION, TYPE)
@ -877,6 +877,68 @@ NOSQL_MAX_RECORDS = 100
# Upper bound for the length search during NoSQL blind extraction
NOSQL_MAX_LENGTH = 1024
# GraphQL endpoint paths to probe when the user supplies a base URL with --graphql (no explicit /graphql)
GRAPHQL_ENDPOINT_PATHS = ("/graphql", "/api/graphql", "/v1/graphql", "/graphql/api", "/graph", "/gql")
# Canonical GraphQL introspection query (the one everyone copy-pastes). Returned schema carries the
# full type system: query/mutation/subscription roots, OBJECT/INPUT_OBJECT/ENUM/SCALAR types, their
# fields/arguments/inputFields with type chains, directives, and deprecation metadata.
GRAPHQL_INTROSPECTION_QUERY = """query IntrospectionForSqlmap {
__schema {
queryType { name }
mutationType { name }
subscriptionType { name }
directives { name args { name type { kind name ofType { kind name ofType { kind name } } } } }
types {
kind
name
fields(includeDeprecated: true) {
name
args {
name
defaultValue
type { kind name ofType { kind name ofType { kind name ofType { kind name } } } }
}
type { kind name ofType { kind name ofType { kind name } } }
}
inputFields {
name
defaultValue
type { kind name ofType { kind name ofType { kind name ofType { kind name } } } }
}
enumValues(includeDeprecated: true) { name }
specifiedByURL
}
}
}"""
# GraphQL error patterns that identify the response as originating from a GraphQL layer (parse,
# validation, execution, or APQ errors). Used by the heuristic in checks.py and for error-based
# detection inside the GraphQL engine.
GRAPHQL_PARSE_ERRORS = (
r'"code"\s*:\s*"GRAPHQL_PARSE_FAILED"',
r"\bSyntax Error:\s*[^\"]",
r"\bExpected Name,\s*found\b",
r"\bUnexpected\s+<EOF>\b",
)
GRAPHQL_VALIDATION_ERRORS = (
r'"code"\s*:\s*"GRAPHQL_VALIDATION_FAILED"',
r"\bCannot query field\s+\"[^\"]+\"\s+on type\s+\"[^\"]+\"",
r"\bUnknown argument\s+\"[^\"]+\"\s+on field\s+\"[^\"]+\"",
r"\bField\s+\"[^\"]+\"\s+argument\s+\"[^\"]+\"\s+of type\s+\"[^\"]+\"\s+is required\b",
r"\bVariable\s+\"\$[^\"]+\"\s+got invalid value\b",
r"\bExpected type\s+[^,]+,\s*found\b",
r"\bDid you mean\s+\"[^\"]+\"\b",
)
GRAPHQL_APQ_ERRORS = (
r"\bPersistedQueryNotFound\b",
r"\bPersistedQueryNotSupported\b",
)
GRAPHQL_RUNTIME_ERRORS = (
r"\bGraphQL\s+(?:resolver\s+)?error\b",
)
GRAPHQL_ERROR_REGEX = "(?:%s)" % '|'.join(GRAPHQL_PARSE_ERRORS + GRAPHQL_VALIDATION_ERRORS + GRAPHQL_APQ_ERRORS + GRAPHQL_RUNTIME_ERRORS)
# Length of prefix and suffix used in non-SQLI heuristic checks
NON_SQLI_CHECK_PREFIX_SUFFIX_LENGTH = 6

View file

@ -89,6 +89,7 @@ def vulnTest():
("-u <url> -z \"tec=B\" --hex --fresh-queries --threads=4 --sql-query=\"SELECT * FROM users\"", ("SELECT * FROM users [30]", "nameisnull")),
("-u \"<url>&echo=foobar*\" --flush-session", ("might be vulnerable to cross-site scripting",)),
("-u \"<base>nosql?name=luther&password=x\" -p password --nosql --flush-session", ("is vulnerable to NoSQL injection", "back-end: 'MongoDB'", "NoSQL: GET parameter 'password'", "s3cr3t")), # NoSQL (MongoDB) operator-injection detection + blind regexp extraction
("-u \"<base>graphql\" --graphql --flush-session", ("found GraphQL endpoint", "introspection returned", "skipping 2 mutation slot", "GraphQL boolean-based blind", "in-band data exposure", "back-end DBMS: 'SQLite'", "banner: '3.", "available tables [2]: users, creds", "dumped table 'creds'", "db3a16990a0008a3b04707fdef6584a0", "graphql scan complete")), # GraphQL: endpoint detection + introspection + mutation-skip + boolean-blind/in-band + back-end fingerprint + batched blind dump of an injection-only table (SQLite-backed)
("-u \"<url>&query=*\" --flush-session --technique=Q --banner", ("Title: SQLite inline queries", "banner: '3.")),
("-d \"<direct>\" --flush-session --dump -T creds --dump-format=SQLITE --binary-fields=password_hash --where \"user_id=5\"", ("3137396164343563366365326362393763663130323965323132303436653831", "dumped to SQLITE database")),
("-d \"<direct>\" --flush-session --banner --schema --sql-query=\"UPDATE users SET name='foobar' WHERE id=4; SELECT * FROM users; SELECT 987654321\"", ("banner: '3.", "INTEGER", "TEXT", "id", "name", "surname", "4,foobar,nameisnull", "'987654321'",)),

View file

@ -418,6 +418,9 @@ def cmdLineParser(argv=None):
techniques.add_argument("--nosql", dest="nosql", action="store_true",
help="Test for NoSQL injection (e.g. MongoDB, CouchDB, Neo4j)")
techniques.add_argument("--graphql", dest="graphql", action="store_true",
help="Test for GraphQL injection (introspection, field/argument fuzzing, SQL/NoSQL payload families)")
techniques.add_argument("--time-sec", dest="timeSec", type=int,
help="Seconds to delay the DBMS response (default %d)" % defaults.timeSec)

View file

@ -0,0 +1,8 @@
#!/usr/bin/env python
"""
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
See the file 'LICENSE' for copying permission
"""
pass

File diff suppressed because it is too large Load diff

680
tests/test_graphql.py Normal file
View file

@ -0,0 +1,680 @@
#!/usr/bin/env python
"""
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
See the file 'LICENSE' for copying permission
Offline, deterministic tests for the GraphQL injection engine. Mock oracles stand in for the
HTTP/GraphQL layer so endpoint detection, introspection parsing, slot enumeration, query
construction, and boolean/error-based detection can be exercised without a live target.
"""
import json
import re
import unittest
from _testutils import bootstrap
bootstrap()
import lib.techniques.graphql.inject as gi
# --- Mock helpers -----------------------------------------------------------
MATCH = '{"data":{"user":{"id":1,"name":"luther","surname":"blisset"}}}'
NOMATCH = '{"data":{"user":null}}'
DB_ERROR = '{"errors":[{"message":"You have an error in your SQL syntax; check the manual...","path":["user"]}]}'
GQL_PARSE_ERROR = '{"errors":[{"message":"Syntax Error: Expected Name, found )","extensions":{"code":"GRAPHQL_PARSE_FAILED"}}]}'
MOCK_SCHEMA = {
"data": {"__schema": {
"queryType": {"name": "Query"},
"mutationType": {"name": "Mutation"},
"subscriptionType": None,
"directives": [],
"types": [
{"kind": "OBJECT", "name": "Query", "fields": [
{"name": "user", "args": [
{"name": "username", "defaultValue": None,
"type": {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "String", "ofType": None}}}
], "type": {"kind": "OBJECT", "name": "User", "ofType": None}},
{"name": "byId", "args": [
{"name": "id", "defaultValue": None,
"type": {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "Int", "ofType": None}}}
], "type": {"kind": "OBJECT", "name": "User", "ofType": None}},
{"name": "login", "args": [
{"name": "username", "defaultValue": None,
"type": {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "String", "ofType": None}}},
{"name": "password", "defaultValue": None,
"type": {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "String", "ofType": None}}},
], "type": {"kind": "OBJECT", "name": "AuthPayload", "ofType": None}},
{"name": "version", "args": [],
"type": {"kind": "SCALAR", "name": "String", "ofType": None}},
], "inputFields": None, "enumValues": None},
{"kind": "SCALAR", "name": "String"},
{"kind": "SCALAR", "name": "Int"},
{"kind": "SCALAR", "name": "Float"},
{"kind": "SCALAR", "name": "ID"},
{"kind": "OBJECT", "name": "User", "fields": [
{"name": "id", "args": [], "type": {"kind": "SCALAR", "name": "Int", "ofType": None}},
{"name": "name", "args": [], "type": {"kind": "SCALAR", "name": "String", "ofType": None}},
], "inputFields": None, "enumValues": None},
{"kind": "OBJECT", "name": "AuthPayload", "fields": [
{"name": "token", "args": [], "type": {"kind": "SCALAR", "name": "String", "ofType": None}},
{"name": "user", "args": [], "type": {"kind": "OBJECT", "name": "User", "ofType": None}},
], "inputFields": None, "enumValues": None},
]
}}
}
def _slot(opType, rootName, fieldName, argName, strategy="string",
returnKind="OBJECT", returnType="User",
returnSel="{ id name }", allArgs=None):
"""Test helper: build a minimal Slot with sensible defaults"""
if allArgs is None:
argType = {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "String", "ofType": None}}
if strategy == "numeric":
argType = {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "Int", "ofType": None}}
elif strategy == "id_dual":
argType = {"kind": "SCALAR", "name": "ID"}
allArgs = [(argName, argType, None)]
return gi.Slot(opType, rootName, fieldName, allArgs, argName, strategy,
returnKind, returnType, returnSel)
# --- Tests -----------------------------------------------------------------
class TestGraphqlHelpers(unittest.TestCase):
"""Unit tests for type-walking, classification, and response parsing"""
def test_unwrap_simple_scalar(self):
chain = gi._unwrapType({"kind": "SCALAR", "name": "String"})
self.assertEqual(chain, [("SCALAR", "String")])
def test_unwrap_non_null(self):
chain = gi._unwrapType({"kind": "NON_NULL", "name": None,
"ofType": {"kind": "SCALAR", "name": "String"}})
self.assertEqual(chain, [("NON_NULL", None), ("SCALAR", "String")])
def test_unwrap_list_non_null(self):
chain = gi._unwrapType({"kind": "LIST", "name": None,
"ofType": {"kind": "NON_NULL", "name": None,
"ofType": {"kind": "OBJECT", "name": "User"}}})
self.assertEqual(chain, [("LIST", None), ("NON_NULL", None), ("OBJECT", "User")])
def test_classify_string(self):
self.assertEqual(gi._classifyArg({"kind": "NON_NULL", "ofType": {"kind": "SCALAR", "name": "String"}}), "string")
def test_classify_int(self):
self.assertEqual(gi._classifyArg({"kind": "SCALAR", "name": "Int"}), "numeric")
def test_classify_float(self):
self.assertEqual(gi._classifyArg({"kind": "SCALAR", "name": "Float"}), "numeric")
def test_classify_id(self):
self.assertEqual(gi._classifyArg({"kind": "SCALAR", "name": "ID"}), "id_dual")
def test_classify_boolean_is_none(self):
self.assertIsNone(gi._classifyArg({"kind": "SCALAR", "name": "Boolean"}))
def test_escape_graphql_string(self):
self.assertEqual(gi._escapeGraphQLString('test"quote'), 'test\\"quote')
self.assertEqual(gi._escapeGraphQLString("back\\slash"), "back\\\\slash")
def test_is_graphql_response_with_typename(self):
self.assertTrue(gi._isGraphQLResponse('{"data":{"__typename":"Query"}}'))
def test_is_graphql_response_parse_error(self):
self.assertTrue(gi._isGraphQLResponse(
'{"errors":[{"message":"Syntax Error: Unexpected <EOF>","extensions":{"code":"GRAPHQL_PARSE_FAILED"}}]}'))
def test_not_graphql_response(self):
self.assertFalse(gi._isGraphQLResponse("<html><body>hello</body></html>"))
self.assertFalse(gi._isGraphQLResponse(""))
self.assertFalse(gi._isGraphQLResponse('{"data":{"user":{"id":1}}}')) # no __typename, no graphql error phrasing
def test_error_text_extraction(self):
err = gi._errorText(DB_ERROR)
self.assertIn("SQL syntax", err)
self.assertIn("check the manual", err)
def test_error_text_from_parse_failure(self):
err = gi._errorText(GQL_PARSE_ERROR)
self.assertIn("GRAPHQL_PARSE_FAILED", err)
self.assertIn("Syntax Error", err)
def test_slot_value_from_data(self):
val = gi._slotValue(MATCH)
self.assertIn("luther", val)
self.assertIn("blisset", val)
def test_slot_value_null(self):
val = gi._slotValue(NOMATCH)
self.assertIn("null", val)
class TestGraphqlIntrospection(unittest.TestCase):
"""Schema walking and slot enumeration"""
def test_extract_slots(self):
schema = MOCK_SCHEMA["data"]["__schema"]
slots = gi._extractSlots(schema)
names = [(s.parentType, s.fieldName, s.targetArg, s.strategy) for s in slots]
self.assertIn(("Query", "user", "username", "string"), names)
self.assertIn(("Query", "byId", "id", "numeric"), names)
def test_login_has_two_args(self):
"""login(username: String!, password: String!) -- both required args should be in Slot"""
schema = MOCK_SCHEMA["data"]["__schema"]
slots = gi._extractSlots(schema)
loginSlots = [s for s in slots if s.fieldName == "login"]
self.assertEqual(len(loginSlots), 2)
for s in loginSlots:
self.assertEqual(len(s.allArgs), 2) # username + password
def test_scalar_return_has_empty_selection(self):
"""version: String -- field with no args produces no slots"""
schema = MOCK_SCHEMA["data"]["__schema"]
slots = gi._extractSlots(schema)
# version has no args, so it should NOT appear in slots
versionSlots = [s for s in slots if s.fieldName == "version"]
self.assertEqual(len(versionSlots), 0)
class TestGraphqlBuildQuery(unittest.TestCase):
"""GraphQL query document construction from Slot + value"""
def test_string_arg(self):
slot = _slot("query", "Query", "user", "username", "string")
q = gi._buildQuery(slot, "luther")
self.assertIn('user(username:"luther")', q)
self.assertIn("{ id name }", q)
def test_string_injection_payload(self):
slot = _slot("query", "Query", "user", "username", "string")
q = gi._buildQuery(slot, "' OR '1'='1")
self.assertIn("' OR '1'='1", q)
def test_numeric_with_payload_is_empty(self):
"""Numeric GraphQL literals cannot carry SQL payloads; _buildQuery returns ''"""
slot = _slot("query", "Query", "byId", "id", "numeric")
q = gi._buildQuery(slot, "1 OR 1=1")
self.assertEqual(q, "")
def test_numeric_with_valid_integer(self):
slot = _slot("query", "Query", "byId", "id", "numeric")
q = gi._buildQuery(slot, "1")
self.assertIn("byId(id:1)", q)
def test_id_string(self):
slot = _slot("query", "Query", "get", "uid", "id_dual")
q = gi._buildQuery(slot, "abc")
self.assertIn('get(uid:"abc")', q)
def test_id_numeric(self):
slot = _slot("query", "Query", "get", "uid", "id_dual")
q = gi._buildQuery(slot, "123")
self.assertIn("get(uid:123)", q)
def test_two_required_args_renders_both(self):
"""login(username: String!, password: String!) -- uninjected sibling gets a default"""
allArgs = [
("username", {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "String", "ofType": None}}, None),
("password", {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "String", "ofType": None}}, None),
]
slot = gi.Slot("query", "Query", "login", allArgs, "password", "string",
"OBJECT", "AuthPayload", "{ token user { id name } }")
q = gi._buildQuery(slot, "' OR '1'='1")
self.assertIn("login(", q)
self.assertIn("username:", q) # required sibling rendered
self.assertIn("password:", q) # target arg rendered
self.assertIn("' OR '1'='1", q)
def test_mutation_wraps_with_mutation_keyword(self):
allArgs = [
("id", {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "Int", "ofType": None}}, None),
("email", {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "String", "ofType": None}}, None),
]
slot = gi.Slot("mutation", "Mutation", "updateUser", allArgs, "email", "string",
"OBJECT", "User", "{ id name }")
q = gi._buildQuery(slot, "x' OR '1'='1")
self.assertTrue(q.startswith("mutation {"))
class TestGraphqlBooleanDetection(unittest.TestCase):
"""Boolean-based detection via mock oracle"""
def setUp(self):
self._gql = gi._gqlSend
gi.conf = type("C", (), {"url": "http://test/graphql"})()
pages = {"true": MATCH, "false": NOMATCH}
def fakeSend(endpoint, query, variables=None):
if "'1'='1" in query:
return pages["true"], 200
if "'1'='2" in query:
return pages["false"], 200
return NOMATCH, 200
gi._gqlSend = fakeSend
def tearDown(self):
gi._gqlSend = self._gql
def test_boolean_detected(self):
slot = _slot("query", "Query", "user", "username", "string")
oracleType, template = gi._detectBoolean(slot, "http://test/graphql")
self.assertIsNotNone(oracleType)
self.assertIn("boolean-based", oracleType)
def test_numeric_skipped(self):
slot = _slot("query", "Query", "byId", "id", "numeric")
oracleType, template = gi._detectBoolean(slot, "http://test/graphql")
self.assertIsNone(oracleType)
class TestGraphqlErrorDetection(unittest.TestCase):
"""Error-based detection via mock oracle"""
def setUp(self):
self._gql = gi._gqlSend
gi.conf = type("C", (), {"url": "http://test/graphql"})()
def fakeSend(endpoint, query, variables=None):
if "'" in query and "'1'='1" not in query:
return DB_ERROR, 500
return NOMATCH, 200
gi._gqlSend = fakeSend
def tearDown(self):
gi._gqlSend = self._gql
def test_error_detected(self):
slot = _slot("query", "Query", "user", "username", "string")
oracleType, detail = gi._detectError(slot, "http://test/graphql")
self.assertEqual(oracleType, "error-based")
class TestGraphqlParseRows(unittest.TestCase):
"""JSON data row parsing for in-band dumps"""
def test_single_object(self):
page = '{"data":{"user":{"id":1,"name":"luther","surname":"blisset"}}}'
slot = _slot("query", "Query", "user", "username", "string")
result = gi._parseRows(page, slot)
self.assertIsNotNone(result)
columns, rows = result
self.assertIn("id", columns)
self.assertIn("name", columns)
self.assertEqual(rows[0][columns.index("name")], "luther")
def test_list_of_objects(self):
page = '{"data":{"search":[{"id":1,"name":"luther"},{"id":2,"name":"fluffy"}]}}'
slot = _slot("query", "Query", "search", "term", "string")
columns, rows = gi._parseRows(page, slot)
self.assertEqual(len(rows), 2)
names = [r[columns.index("name")] for r in rows]
self.assertIn("luther", names)
self.assertIn("fluffy", names)
def test_null_returns_none(self):
page = '{"data":{"user":null}}'
slot = _slot("query", "Query", "user", "username", "string")
self.assertIsNone(gi._parseRows(page, slot))
def test_non_json_returns_none(self):
self.assertIsNone(gi._parseRows("<html></html>", None))
class TestGraphqlGrid(unittest.TestCase):
"""ASCII table rendering"""
def test_grid(self):
output = gi._grid(["id", "name"], [["1", "luther"], ["2", "fluffy"]])
self.assertIn("id", output)
self.assertIn("luther", output)
self.assertIn("fluffy", output)
self.assertIn("+-", output)
self.assertIn("|", output)
class TestGraphqlEndpointDetection(unittest.TestCase):
"""Mock endpoint detection"""
def setUp(self):
self._gql = gi._gqlSend
def fakeSend(endpoint, query, variables=None):
if endpoint.endswith("/graphql") and "__typename" in query:
return '{"data":{"__typename":"Query"}}', 200
return 'Not Found', 404
gi._gqlSend = fakeSend
def tearDown(self):
gi._gqlSend = self._gql
def test_detect_direct_url(self):
endpoint, page = gi._detectEndpoint("http://test/graphql", probePaths=False)
self.assertEqual(endpoint, "http://test/graphql")
def test_detect_via_probe(self):
endpoint, page = gi._detectEndpoint("http://test", probePaths=True)
self.assertEqual(endpoint, "http://test/graphql")
def test_not_graphql_endpoint(self):
def fakeSend(endpoint, query, variables=None):
return 'Not Found', 404
gi._gqlSend = fakeSend
endpoint, page = gi._detectEndpoint("http://test", probePaths=True)
self.assertIsNone(endpoint)
class TestGraphqlIntrospectionFallback(unittest.TestCase):
"""Introspection without specifiedByURL (older servers)"""
def setUp(self):
self._gql = gi._gqlSend
gi.conf = type("C", (), {"url": "http://test/graphql"})()
def tearDown(self):
gi._gqlSend = self._gql
def test_fallback_without_specifiedByURL(self):
calls = []
def fakeSend(endpoint, query, variables=None):
calls.append(query)
if "specifiedByURL" in query:
return '{"errors":[{"message":"Unknown field specifiedByURL"}]}', 400
return json.dumps(MOCK_SCHEMA), 200
gi._gqlSend = fakeSend
schema = gi._introspect("http://test/graphql")
self.assertIsNotNone(schema)
self.assertIn("queryType", schema)
self.assertEqual(len(calls), 2) # first fails, second succeeds
class TestGraphqlNestedReturnSelection(unittest.TestCase):
"""Nested return selections for object-typed fields within the return type"""
def test_auth_payload_nested_user(self):
"""AuthPayload { token, user { id name } } -- selection must nest user sub-fields"""
schema = MOCK_SCHEMA["data"]["__schema"]
slots = gi._extractSlots(schema)
loginSlots = [s for s in slots if s.fieldName == "login"]
self.assertTrue(len(loginSlots) > 0)
# The nested selection should include 'user { ... }' at some level
for s in loginSlots:
self.assertIn("token", s.returnSel)
# user sub-fields should appear
self.assertIn("id", s.returnSel)
self.assertIn("name", s.returnSel)
class TestGraphqlCell(unittest.TestCase):
"""Dump-cell rendering: scalars as text, nested structures as compact JSON, null as NULL"""
def test_scalar(self):
self.assertEqual(gi._cell("luther"), "luther")
self.assertEqual(gi._cell(7), "7")
def test_null(self):
self.assertEqual(gi._cell(None), "NULL")
def test_nested_object_is_json_not_repr(self):
# issue B: a nested object must not leak Python dict syntax into the dump
self.assertEqual(gi._cell({"id": 1, "name": "luther"}), '{"id": 1, "name": "luther"}')
self.assertEqual(gi._cell([1, 2]), "[1, 2]")
class TestGraphqlDialects(unittest.TestCase):
"""Per-DBMS SQL building blocks"""
def test_sqlite_ordinal_and_length(self):
d = gi.DIALECTS["SQLite"]
self.assertEqual(d.length("x"), "LENGTH((x))")
self.assertEqual(d.ordinal("x", 3), "UNICODE(SUBSTR((x),3,1))")
def test_sqlite_rows_handles_nulls(self):
d = gi.DIALECTS["SQLite"]
sql = d.rows(["name", "surname"], "users")
self.assertIn("GROUP_CONCAT", sql)
self.assertIn("COALESCE(CAST(name AS TEXT),'NULL')", sql)
self.assertIn("FROM users", sql)
def test_mysql_uses_sleep_delay(self):
d = gi.DIALECTS["MySQL"]
self.assertEqual(d.delay("1=1", 5), "IF((1=1),SLEEP(5),0)")
def test_sqlite_has_no_delay(self):
self.assertIsNone(gi.DIALECTS["SQLite"].delay)
class TestGraphqlFingerprint(unittest.TestCase):
"""DBMS fingerprinting drives off the universal truth() predicate"""
def test_identifies_sqlite(self):
truth = lambda cond: cond == gi.DIALECTS["SQLite"].fingerprint
self.assertEqual(gi._fingerprint(truth), "SQLite")
def test_identifies_mysql(self):
truth = lambda cond: cond == gi.DIALECTS["MySQL"].fingerprint
self.assertEqual(gi._fingerprint(truth), "MySQL")
def test_unknown_backend(self):
self.assertIsNone(gi._fingerprint(lambda cond: False))
def _mockOracle(target):
"""A synthetic SQLite-like dialect plus truth/truthBatch closures that answer comparison and bit
predicates against a known `target` string - lets the blind extractors be exercised without HTTP."""
dialect = gi.Dialect(
fingerprint="FP", delay=None, banner=None, currentUser=None, currentDb=None,
tables=None, columns=None,
length=lambda expr: "LEN(%s)" % expr,
ordinal=lambda expr, pos: "ORD(%s,%d)" % (expr, pos),
rows=None)
def _value(cond):
pos = None
if cond.startswith("LEN("):
value = len(target)
else: # ORD(<expr>,<pos>)
pos = int(cond[cond.index(",") + 1:cond.rindex(")")])
value = ord(target[pos - 1]) if pos - 1 < len(target) else 0
return value
def truth(cond):
tail = cond[cond.rindex(")") + 1:] # e.g. ">=65"
op = re.match(r"(>=|>|=)", tail).group(1)
num = int(tail[len(op):])
value = _value(cond)
return {">": value > num, ">=": value >= num, "=": value == num}[op]
def truthBatch(conditions):
results = []
for cond in conditions:
bit = re.match(r"\(ORD\(.*?,(\d+)\) & (\d+)\)>0$", cond)
if bit:
pos, mask = int(bit.group(1)), int(bit.group(2))
value = ord(target[pos - 1]) if pos - 1 < len(target) else 0
results.append((value & mask) > 0)
else:
results.append(truth(cond))
return results
return dialect, truth, truthBatch
class TestGraphqlInference(unittest.TestCase):
"""Blind value recovery: sequential bisection and bit-parallel batched extraction"""
def test_sequential_extraction(self):
for target in ("3.45.1", "users,creds", "db3a16990a0008a3b04707fdef6584a0", ""):
dialect, truth, _ = _mockOracle(target)
self.assertEqual(gi._inferExpr(truth, dialect, "EXPR"), target)
def test_batched_extraction_matches_sequential(self):
for target in ("3.45.1", "users,creds", "luther~~~blisset^^^fluffy~~~bunny"):
dialect, _, truthBatch = _mockOracle(target)
self.assertEqual(gi._inferExprBatched(truthBatch, dialect, "EXPR"), target)
def test_batched_empty(self):
dialect, _, truthBatch = _mockOracle("")
self.assertEqual(gi._inferExprBatched(truthBatch, dialect, "EXPR"), "")
class TestGraphqlDumpTable(unittest.TestCase):
"""Whole-table dump: column list + row scalar split back into a grid"""
def test_dump_table(self):
responses = {
"(SELECT GROUP_CONCAT(name) FROM pragma_table_info('users'))": "id,name",
}
rowScalar = "1%snull^^^2%sluther" % ("~~~", "~~~") # two rows, two columns
def infer(expr, maxLen=gi.MAX_LENGTH):
if expr in responses:
return responses[expr]
return rowScalar # the GROUP_CONCAT row dump
columns, rows = gi._dumpTable(infer, gi.DIALECTS["SQLite"], "users")
self.assertEqual(columns, ["id", "name"])
self.assertEqual(rows, [["1", "null"], ["2", "luther"]])
class TestGraphqlMakeOracle(unittest.TestCase):
"""Universal truth()/truthBatch() primitive built from a slot's true/false contrast"""
USER_OBJ = {"id": 1, "name": "luther", "surname": "blisset"}
def setUp(self):
self._gql = gi._gqlSend
def fakeSend(endpoint, query, variables=None):
if "a0:" in query: # batched, aliased request
data = {}
for m in re.finditer(r'(a\d+):\w+\(\w+:"[^"]*\((1=1|1=2)\)', query):
data[m.group(1)] = self.USER_OBJ if m.group(2) == "1=1" else None
return json.dumps({"data": data}), 200
if "(1=1)" in query:
return json.dumps({"data": {"user": self.USER_OBJ}}), 200
return json.dumps({"data": {"user": None}}), 200
gi._gqlSend = fakeSend
def tearDown(self):
gi._gqlSend = self._gql
def test_truth_primitive(self):
slot = _slot("query", "Query", "user", "username", "string")
truth, truthBatch = gi._makeOracle(slot, "http://test/graphql")
self.assertIsNotNone(truth)
self.assertTrue(truth("1=1"))
self.assertFalse(truth("1=2"))
def test_batched_truth(self):
slot = _slot("query", "Query", "user", "username", "string")
_, truthBatch = gi._makeOracle(slot, "http://test/graphql")
self.assertEqual(truthBatch(["1=1", "1=2", "1=1"]), [True, False, True])
class TestVulnserverGraphqlParser(unittest.TestCase):
"""The vulnserver's selection parser must survive aliased batches and bracketed payloads"""
def setUp(self):
from extra.vulnserver import vulnserver
self.vs = vulnserver
def test_match_skips_quoted_brackets(self):
text = 'user(username:"x\' OR (1=1)-- "){ id }'
end = self.vs._graphql_match(text, text.index("("))
self.assertEqual(text[end - 1], ")") # the args close-paren, not one inside the string
def test_single_field(self):
sels = self.vs._graphql_selections('user(username:"luther"){ id name }')
self.assertEqual(sels, [(None, "user", 'username:"luther"')])
def test_aliased_batch_with_payloads(self):
body = 'a0:user(username:"x\' OR (1=1)-- "){ id } a1:user(username:"x\' OR (1=2)-- "){ id }'
sels = self.vs._graphql_selections(body)
self.assertEqual([(a, f) for a, f, _ in sels], [("a0", "user"), ("a1", "user")])
self.assertIn("(1=1)", sels[0][2])
self.assertIn("(1=2)", sels[1][2])
def test_nested_selection_set(self):
sels = self.vs._graphql_selections('login(username:"a", password:"b"){ token user { id name } }')
self.assertEqual(len(sels), 1)
self.assertEqual(sels[0][1], "login")
class TestGraphqlSiblingDefaults(unittest.TestCase):
"""Required sibling arguments must use their real type, not be hardcoded as strings"""
def test_numeric_sibling_not_quoted(self):
"""field(name: String!, limit: Int!) -- injecting 'name' renders limit:0, not limit:\"0\""""
allArgs = [
("name", {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "String", "ofType": None}}, None),
("limit", {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "Int", "ofType": None}}, None),
]
slot = gi.Slot("query", "Query", "search", allArgs, "name", "string",
"OBJECT", "User", "{ id }")
q = gi._buildQuery(slot, "' OR '1'='1")
self.assertIn("limit:0", q)
self.assertNotIn('limit:"0"', q)
def test_boolean_sibling_gets_default_string(self):
"""field(name: String!, active: Boolean!) -- Boolean gets \"x\" since there is no Boolean strategy"""
allArgs = [
("name", {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "String", "ofType": None}}, None),
("active", {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "Boolean", "ofType": None}}, None),
]
slot = gi.Slot("query", "Query", "toggle", allArgs, "name", "string",
"OBJECT", "User", "{ id }")
q = gi._buildQuery(slot, "test")
self.assertIn('active:"x"', q)
class TestGraphqlScalarReturnSelection(unittest.TestCase):
"""Scalar and list-of-scalar returns must not get a spurious {__typename} selection"""
def test_scalar_return_has_no_selection(self):
"""version(format: String): String -- no sub-selection"""
allArgs = [
("format", {"kind": "SCALAR", "name": "String"}, None),
]
slot = gi.Slot("query", "Query", "version", allArgs, "format", "string",
"SCALAR", "String", None)
q = gi._buildQuery(slot, "json")
self.assertIn('version(format:"json")', q)
self.assertNotIn("{", q.split(")")[1] if ")" in q else q)
def test_list_of_scalars_has_no_selection(self):
"""tags(prefix: String): [String] -- no sub-selection"""
allArgs = [
("prefix", {"kind": "SCALAR", "name": "String"}, None),
]
slot = gi.Slot("query", "Query", "tags", allArgs, "prefix", "string",
"SCALAR", "String", None)
q = gi._buildQuery(slot, "a")
self.assertIn('tags(prefix:"a")', q)
self.assertNotIn("{", q.split(")")[1] if ")" in q else q)
class TestGraphqlUnicodeSafety(unittest.TestCase):
"""All string conversions must be safe under Python 2 and 3 for non-ASCII data"""
def test_escape_graphql_string_unicode(self):
escaped = gi._escapeGraphQLString(u"caf\xe9")
self.assertIn("caf", escaped)
def test_error_text_unicode(self):
page = u'{"errors":[{"message":"caf\xe9","extensions":{"code":"SYNTAX_ERROR"}}]}'
text = gi._errorText(page)
self.assertIn("caf", text)
def test_cell_unicode(self):
self.assertIn("caf", gi._cell(u"caf\xe9"))
if __name__ == "__main__":
unittest.main()