Skip to content
highCVSS 8.6CWE-89A03:2021 – Injection

APIs and GraphQL

Filter, sort and search parameters in REST and GraphQL are built for flexibility — which usually means built by concatenation. Where modern APIs reintroduce a solved problem.

Why APIs Regress

Form-driven web applications ask narrow questions, so their queries are static and parameterise cleanly. APIs are built for flexibility — clients want to filter on arbitrary fields, sort by anything, page, and select sparse fieldsets.

That flexibility has to be translated into SQL at runtime, and the translation layer is where concatenation creeps back in. A generic filter engine that accepts ?filter[status][gte]=5 has to turn status, gte, and 5 into SQL. The value can be parameterised. The column name and the operator cannot — and that is where the bug lives.

APIs also tend to be tested less thoroughly than the web UI: no forms to click through, scanners that need a schema they were not given, and endpoints that never appear in a sitemap.

REST Parameters Worth Probing

Parameter shapeExampleWhy it is risky
Sort?sort=name ?order_by=-createdIdentifier context. Cannot be parameterised.
Generic filter?filter[age][gt]=30Column AND operator both come from input.
Field selection?fields=id,nameColumn list built by joining input.
Search?q=foo&in=name,emailColumn list plus a LIKE pattern.
Pagination?limit=10&offset=20Often concatenated as integers without validation.
Aggregation?group_by=status&having=count>5Whole clauses assembled from input.
JSON body filters{"where":{"role":"admin"}}Deep structures mapped to SQL by a generic walker.
Batch / bulk IDs{"ids":[1,2,3]}IN() lists built by joining array elements.

The Generic Filter Engine

JavaScriptfilters.jsVulnerable
// A filter engine that maps ?filter[col][op]=val to SQL.
// The value is parameterised. The column and operator are not —
// and cannot be, because placeholders do not work for identifiers.
function buildWhere(filters) {
  const clauses = []
  const params = []
  for (const [col, ops] of Object.entries(filters)) {
    for (const [op, val] of Object.entries(ops)) {
      const sqlOp = { eq: '=', gt: '>', lt: '<', like: 'LIKE' }[op] || '='
      clauses.push(`${col} ${sqlOp} ?`)   // <-- col is attacker-controlled
      params.push(val)
    }
  }
  return { sql: clauses.join(' AND '), params }
}

// ?filter[id EXTRACTVALUE(1,CONCAT(0x7e,(SELECT password FROM users LIMIT 1)))--][eq]=1
// The "column" is a whole expression.

JSON Bodies and Content Types

Injection through a JSON body works exactly as it does through a query string, with two practical differences.

Escaping is doubled. A single quote in JSON is fine, but a backslash or a double quote must be escaped for JSON and may then be interpreted by the SQL layer. Get the JSON encoding right first or you will misdiagnose a working payload as a failure.

Type confusion is available. JSON distinguishes "1" from 1 from [1] from {"a":1}. Backends that expect a scalar and receive an array or object often stringify it in a way that produces unexpected SQL — and validation middleware frequently only checks scalars.

{"id": "1 OR 1=1"}          → numeric context, string-typed
{"id": 1}                    → baseline
{"ids": [1, "2 OR 1=1"]}     → IN() list built by joining
{"sort": {"$expr": "..."}}   → operator injection, see /guide/nosql-injection

Also try changing the content type. An endpoint that accepts both JSON and form encoding may route them through different parsers with different validation.

GraphQL

GraphQL itself is not SQL and does not introduce SQL injection on its own. The risk is entirely in the resolvers: each one turns arguments into a database query, and a resolver that builds a WHERE clause from an argument is as vulnerable as any REST handler.

What makes GraphQL notable is the breadth of exposed input. A REST API exposes the parameters someone decided to expose; a GraphQL schema exposes every argument on every field, including nested resolvers that are rarely exercised or reviewed. Filter and sort arguments on nested types are a productive place to look.

Introspection is your map. If it is enabled, you get every field and argument at once.

GraphQL enumeration and injection

JSON
// Introspection dumps the entire schema, including every argument
// on every field. This is your parameter list.
{
  "query": "{ __schema { types { name fields { name args { name type { name } } } } } }"
}

// Introspection disabled? Field suggestions often still leak names:
{ "query": "{ usr { id } }" }
// => "Cannot query field 'usr'. Did you mean 'user' or 'users'?"

// Tools: clairvoyance for schema recovery without introspection,
// graphw00f for engine fingerprinting.

Testing API Surfaces

Get the parameter list first. OpenAPI/Swagger documents, GraphQL introspection, and the JavaScript bundle of the front end all enumerate endpoints and arguments far more reliably than crawling. /openapi.json, /swagger.json, /.well-known/, and source maps are worth checking directly.

Probe identifiers, not just values. The value is the part most likely to be parameterised. Put payloads in the column position: ?sort=id-- -, ?fields=id,(SELECT 1), ?filter[id)]=1.

Watch for the operator slot. In filter[col][op]=val, op is mapped through a lookup that often falls back to a default rather than rejecting. If op=zzz behaves like op=eq instead of erroring, the mapping is permissive — test whether it is permissive enough to inject.

Expect JSON error bodies. APIs return structured errors far more often than HTML applications, and those errors frequently include the database message. That makes error-based extraction unusually easy here.

Mind the async paths. Endpoints that queue work and return 202 Accepted give you no response to inspect. Use out-of-band or time-based confirmation, and see Second-Order.

Version-pin your testing. /v1/ and /v2/ of the same endpoint often share a backend but not the validation layer.

Prevention

  • Parameterise values; allowlist identifiers. There is no third option. Every dynamic column, table, operator, or direction needs a fixed map.
  • Reject unknown fields rather than defaulting. A filter engine that ignores unrecognised columns hides bugs; one that returns 400 surfaces them in testing.
  • Type the schema tightly. In GraphQL, use input objects and enums rather than String filter arguments — the framework then rejects bad input before your resolver runs. In REST, use a schema validator on the request body.
  • Disable introspection in production as a hardening measure, while understanding that field suggestions still leak names and that this does not fix a vulnerable resolver.
  • Cap complexity and depth in GraphQL. Not an injection control, but it limits the blast radius of a resolver that turns out to be expensive or exploitable.
  • Include APIs in the test scope explicitly. They are routinely omitted because there is no UI to click.