GraphQL 安全
前言
GraphQL 作为一种由 Facebook 开源的 API 查询语言,凭借其按需获取、单端点、强类型等特性,在前后端分离和微服务架构中得到了广泛应用。然而,GraphQL 的灵活性也带来了与传统 REST API 截然不同的安全挑战。REST API 的攻击面通常分散在多个端点(如 /api/users、/api/posts),而 GraphQL 将所有查询集中在单一端点,攻击者只需一个 POST 请求即可发起复杂的攻击向量。本文将深入剖析 GraphQL 特有的安全威胁,并给出切实可行的防御方案。
一、GraphQL 安全威胁全景
1.1 GraphQL 与 REST 的安全差异
| 维度 | REST API | GraphQL API |
|---|---|---|
| 攻击面 | 分散在多个 URL 端点 | 单一端点,攻击者集中火力 |
| 数据暴露 | 端点固定返回固定字段 | 客户端可精确指定任意嵌套字段 |
| 请求模式 | 单一资源的简单请求 | 可携带复杂的嵌套查询和图遍历 |
| 可见性 | 端点路径可见,返回结构不可变 | Introspection 可泄露完整 Schema |
| 限流粒度 | 按请求数/IP 限流 | 需要按查询复杂度动态计算 |
| 认证与授权 | 一般在网关层统一处理 | 需逐字段实现授权校验 |
GraphQL 的核心安全问题源于其"信任客户端"的设计假设——服务端默认认为客户端不会恶意构造超深嵌套或高并发的查询。攻击者可以利用这一假设,用极少的请求消耗大量的服务端资源。
1.2 攻击面概览
- 信息泄露:通过 Introspection 查询获取 Schema 细节,发现隐藏的 API 和敏感字段
- 资源耗尽:通过深度递归查询、批量别名查询、复杂关联查询消耗 CPU 和数据库连接
- 授权绕过:GraphQL 的字段选择能力使得攻击者可以越权访问同一实体下本无权访问的字段
- 注入攻击:虽然 GraphQL 使用参数化查询,但自定义 Resolver 中的 SQL/NoSQL 拼接仍然存在注入风险
- 批量操作滥用:利用 GraphQL 别名的并发查询能力,在单次请求中获取海量数据
二、Introspection 查询利用与防御
2.1 Introspection 原理
GraphQL 的 Introspection 机制是其区别于 REST 的重要特性,它允许客户端通过 __schema 或 __type 元字段查询 API 的完整 Schema 信息。开发者工具(如 GraphiQL、Apollo Studio)和代码生成工具都依赖此机制工作。然而,在生产环境中,这也为攻击者提供了 API 的"白皮书"。
2.2 攻击示例
攻击者可以通过以下查询获取完整的 Schema 定义:
# 获取所有类型和字段
query IntrospectionQuery {
__schema {
types {
name
kind
description
fields {
name
type {
name
kind
ofType {
name
kind
}
}
}
}
}
}通过该查询,攻击者能够发现:
- 所有实体类型及其关联关系
- 每个类型下的所有字段(包括未在文档中公开的敏感字段)
- 变更(Mutation)操作,如
resetPassword、deleteUser、adminExecute - 输入参数类型和枚举值
2.3 生产环境禁用方案
方案一:通过中间件拦截(推荐)
// Node.js + Express 示例
const { createHandler } = require('graphql-http/lib/use/express');
const handler = createHandler({
schema: schema,
context: async (req) => ({
isProduction: process.env.NODE_ENV === 'production',
}),
// 在生产环境禁用 Introspection
validationRules: process.env.NODE_ENV === 'production'
? [noSchemaIntrospectionCustomRule]
: [],
});方案二:在网关层过滤
# 使用 API 网关配置(如 Kong、APISIX)
# 拦截包含 __schema 或 __type 的查询请求
plugins:
- name: request-transformer
config:
remove:
body:
- query
- name: graphql-proxy
config:
forbid_introspection: true方案三:使用 GraphQL Armor 插件
const { Envelop } = require('@envelop/core');
const { useDisableIntrospection } = require('@envelop/disable-introspection');
const envelop = Envelop({
plugins: [
useDisableIntrospection(),
// 其他插件...
],
});注意:禁用 Introspection 属于"通过不公开来保密"的防御手段,不应作为唯一防线。攻击者仍可通过暴力枚举字段名来探测 Schema。
三、批量查询攻击(Batching Attack)
3.1 攻击原理
GraphQL 的别名(Alias)特性允许客户端在单次查询中请求同一字段的多个不同参数版本。攻击者可以利用这一特性,在一次 HTTP 请求中发起成百上千条数据库查询,远超传统 REST API 的单请求单资源模式。
3.2 攻击示例
# 使用别名批量查询用户数据
query BatchAttack {
user1: user(id: 1) { name email profile { age address } }
user2: user(id: 2) { name email profile { age address } }
user3: user(id: 3) { name email profile { age address } }
# ... 攻击者可以重复数千次
user1000: user(id: 1000) { name email profile { age address } }
}上述查询仅消耗一次 HTTP 请求,但服务端需要执行 1000 次数据库查询。如果每个查询还涉及关联数据的 Resolver 调用,数据库压力将呈指数级增长。
3.3 防御措施
方案一:限制单次查询的别名数量
const { createHandler } = require('graphql-http/lib/use/express');
const { specifiedRules } = require('graphql/validation');
const aliasCountingRule = (context) => ({
Field(node) {
if (node.alias) {
const aliasCount = context.getAliasCount() || 0;
context.setAliasCount(aliasCount + 1);
if (aliasCount + 1 > 50) {
context.reportError(
new GraphQLError('单次查询最多允许 50 个别名')
);
}
}
},
});方案二:使用 DataLoader 批量加载
DataLoader 可以在一个执行批次中合并重复的数据加载请求,有效缓解别名攻击带来的数据库压力。
const DataLoader = require('dataloader');
// 批量用户加载器
const userLoader = new DataLoader(async (ids) => {
const users = await db.user.findAll({ where: { id: ids } });
// 按 ids 顺序返回结果
return ids.map((id) => users.find((u) => u.id === id) || null);
});
const resolvers = {
Query: {
user: (_, { id }, context) => {
// DataLoader 会自动去重和批量
return context.loaders.userLoader.load(id);
},
},
};四、深度递归查询(Deep Recursion Query)
4.1 攻击原理
GraphQL 的关联查询能力允许客户端沿着实体关系进行无限深度的嵌套。攻击者可以构造形如 friends -> friends -> friends ... 的递归查询,使服务端陷入无限循环的数据加载。
4.2 攻击示例
# 深度递归查询:每层都在查询朋友的朋友
query DeepRecursion {
me {
friends {
friends {
friends {
friends {
friends {
name
email
}
}
}
}
}
}
}如果每个朋友平均有 50 个好友,且数据库查询返回 100 字节数据,那么第 5 层将产生 50⁵ = 3.125 亿条记录,内存消耗高达数十 GB。
4.3 防御措施
方案一:限制查询深度
const { depthLimit } = require('graphql-depth-limit');
const handler = createHandler({
schema,
validationRules: [
depthLimit(5, { ignore: ['_id', 'id'] }), // 最大深度 5 层
],
});方案二:自定义深度验证规则
const { GraphQLError } = require('graphql');
function depthLimitValidator(maxDepth) {
return (context) => {
let depth = 0;
return {
Field(node) {
depth++;
if (depth > maxDepth) {
context.reportError(
new GraphQLError(
`查询深度 ${depth} 超过最大允许深度 ${maxDepth}`,
{ nodes: [node] }
)
);
}
},
'Field:exit'() {
depth--;
},
};
};
}五、查询复杂度分析(Query Complexity)
5.1 原理概述
查询复杂度分析是对查询深度和广度的综合评估。与单纯限制深度不同,复杂度分析为每个字段分配一个权重值,动态计算整条查询的"成本",超出阈值则拒绝执行。
5.2 复杂度计算模型
通常的复杂度计算公式为:
字段复杂度 = 基础权重 × 子字段复杂度之和 × 列表预估数量常见字段权重分配:
| 字段类型 | 权重 | 说明 |
|---|---|---|
| 标量字段(string, int) | 1 | 直接返回,无额外开销 |
| 单个关联对象 | 2 | 需要执行一次 Resolver |
| 列表字段(带分页) | 5 + pagination | 每页数量作为额外系数 |
| 变更操作(Mutation) | 10 | 写操作资源消耗更高 |
5.3 实现示例
const { queryComplexity, simpleEstimator, fieldConfigEstimator } = require('graphql-query-complexity');
const handler = createHandler({
schema,
plugins: [
useQueryComplexity({
estimators: [
fieldConfigEstimator(),
simpleEstimator({ defaultComplexity: 1 }),
],
maximumComplexity: 100,
onComplete: (complexity) => {
console.log(`查询复杂度: ${complexity}`);
},
}),
],
});自定义复杂度计算:
const typeDefs = `
type Query {
users(page: Int, limit: Int): [User]
user(id: ID!): User
}
type User {
id: ID!
name: String!
posts(limit: Int): [Post]
}
type Post {
id: ID!
title: String!
comments(limit: Int): [Comment]
}
`;
const resolvers = {
Query: {
users: (_, { page, limit }) => getUsers(page, limit),
},
};
// 自定义复杂度估算器
const customEstimator = () => (context) => {
const { fieldName, returnType } = context;
if (fieldName === 'users') {
// 列表查询:基数 10,乘以分页参数
const limit = context.args.limit || 20;
return 10 + limit;
}
if (fieldName === 'posts' || fieldName === 'comments') {
const limit = context.args.limit || 10;
return 5 + limit;
}
// 标量字段复杂度为 1
return 1;
};六、授权绕过与字段级权限控制
6.1 问题分析
REST API 通常在路由层面实施权限校验——访问 /api/admin/users 需要管理员角色。但在 GraphQL 中,adminUsers 和 regularUsers 可能都暴露在同一个 Query 类型下,攻击者只需发起如下查询:
query StealData {
# 普通查询
me { name email }
# 越权查询
adminUsers { secretKey internalNote role }
}如果 Resolver 层面没有针对每个字段做独立的授权检查,就可能导致未授权的数据泄露。
6.2 字段级授权方案
方案一:在 Resolver 中进行授权检查
const resolvers = {
Query: {
adminUsers: async (_, args, context) => {
// 在每个 Resolver 中显式检查权限
if (!context.user || context.user.role !== 'ADMIN') {
throw new ForbiddenError('无权访问管理员接口');
}
return db.adminUsers.findAll();
},
},
User: {
secretKey: (parent, args, context) => {
if (!context.user || context.user.role !== 'ADMIN') {
return null; // 或抛出错误
}
return parent.secretKey;
},
},
};方案二:使用 graphql-shield 实现声明式权限
const { shield, rule, and, or, not } = require('graphql-shield');
const { ForbiddenError } = require('apollo-server-express');
// 定义权限规则
const isAuthenticated = rule()(async (parent, args, context) => {
return context.user !== null;
});
const isAdmin = rule()(async (parent, args, context) => {
return context.user?.role === 'ADMIN';
});
const isOwner = rule()(async (parent, args, context) => {
return context.user?.id === parent.id || context.user?.id === args.id;
});
// 应用权限到 Schema
const permissions = shield({
Query: {
me: isAuthenticated,
users: isAdmin,
user: and(isAuthenticated, or(isAdmin, isOwner)),
},
Mutation: {
deleteUser: and(isAuthenticated, or(isAdmin, isOwner)),
adminExecute: isAdmin,
},
User: {
secretKey: isAdmin,
internalNote: isAdmin,
email: isAuthenticated, // 登录用户可查看邮箱
},
});
// 在 Apollo Server 中使用
const server = new ApolloServer({
schema: applyMiddleware(schema, permissions),
context: ({ req }) => ({
user: getUserFromToken(req.headers.authorization),
}),
});6.3 DataLoader 上下文传递用户信息
在 DataLoader 中正确传递用户上下文是防止授权绕过的重要环节:
function createLoaders(context) {
return {
userLoader: new DataLoader(async (ids) => {
// 在 DataLoader 中访问 context.user 进行授权检查
if (!context.user) {
return ids.map(() => null);
}
const users = await db.user.findAll({ where: { id: ids } });
return ids.map((id) => {
const user = users.find((u) => u.id === id);
// 非管理员不能查看敏感字段
if (user && context.user.role !== 'ADMIN') {
user.secretKey = undefined;
user.internalNote = undefined;
}
return user || null;
});
}),
};
}
const resolvers = {
Query: {
users: async (_, args, context) => {
// 每个请求创建一个独立的 DataLoader 实例
context.loaders = createLoaders(context);
return context.loaders.userLoader.loadMany(args.ids);
},
},
};七、速率限制:按查询复杂度限流
7.1 为何不能按请求数限流
在 GraphQL 中,不同的查询对资源的消耗差异巨大:
query { me { name } }—— 复杂度约为 2,资源消耗极低- 批量别名查询
query { u1: user(id:1) { ... }, ..., u500: user(id:500) { ... } }—— 复杂度高达数千
如果仅按请求数限流(如每分钟 100 次请求),攻击者只需将大量查询合并到一次请求中即可绕过限制。
7.2 基于复杂度的限流实现
const rateLimit = require('express-rate-limit');
const { getComplexity, simpleEstimator } = require('graphql-query-complexity');
// 中间件:在解析前计算复杂度并限流
async function graphqlComplexityRateLimit(req, res, next) {
const { query } = req.body;
if (!query) {
return next();
}
try {
const complexity = getComplexity({
schema,
query,
estimators: [simpleEstimator({ defaultComplexity: 1 })],
});
// 将复杂度累加到用户账户
const userKey = req.ip;
const currentUsage = await rateLimiter.get(userKey);
const maxComplexityPerMinute = 500;
if (currentUsage + complexity > maxComplexityPerMinute) {
return res.status(429).json({
error: '请求过于频繁',
message: `查询复杂度 ${complexity},已超过每分钟阈值 ${maxComplexityPerMinute}`,
});
}
await rateLimiter.increment(userKey, complexity);
next();
} catch (err) {
next(err);
}
}使用 Redis 实现分布式限流:
const { RateLimiterRedis } = require('rate-limiter-flexible');
const rateLimiter = new RateLimiterRedis({
storeClient: redisClient,
keyPrefix: 'graphql_complexity',
points: 500, // 每分钟总复杂度额度
duration: 60,
blockDuration: 120, // 超限后封禁 120 秒
});
async function complexityRateLimitMiddleware(req, res, next) {
const { query } = req.body;
if (!query) return next();
const complexity = calculateQueryComplexity(query);
const userKey = req.user?.id || req.ip;
try {
await rateLimiter.consume(userKey, complexity);
next();
} catch (rateLimiterRes) {
res.status(429).json({
error: '复杂度超限',
retryAfter: Math.ceil(rateLimiterRes.msBeforeNext / 1000),
});
}
}7.3 限流策略对比
| 策略 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 按请求数 | 简单 API,查询差异小 | 实现简单 | 无法防御批量查询攻击 |
| 按查询复杂度 | 复杂 GraphQL API | 精确反映资源消耗 | 需要维护复杂度映射 |
| 按响应时间 | 对延迟敏感的服务 | 自动适应变化 | 时效滞后,不能预防突发 |
| 混合策略 | 生产环境推荐 | 多维度防护 | 实现复杂度较高 |
八、防御清单与工具
8.1 GraphQL 安全防御清单
基础设施层
- [ ] 生产环境禁用 Introspection 查询
- [ ] 使用 HTTPS 加密全流量
- [ ] 在网关层实施身份认证和 IP 白名单
- [ ] 开启请求体大小限制(建议不超过 256 KB)
- [ ] 配置合理的 CORS 策略
查询层面
- [ ] 限制最大查询深度(建议 5-8 层)
- [ ] 限制每次查询的别名数量(建议不超过 50 个)
- [ ] 实施查询复杂度分析(建议阈值 100-500)
- [ ] 禁用重复字段查询(Duplicate Field)
- [ ] 限制单次查询返回的列表数量上限
授权与数据层面
- [ ] 在每个 Resolver 中实施字段级授权检查
- [ ] 使用 graphql-shield 等声明式权限框架
- [ ] DataLoader 中传递用户上下文,统一处理数据可见性
- [ ] 敏感字段使用
@auth自定义指令标记 - [ ] 实现查询白名单(Persisted Queries)机制
限流与监控层面
- [ ] 基于查询复杂度的速率限制
- [ ] 记录异常查询日志(超深嵌套、高频访问)
- [ ] 设置数据库连接池上限,防止连接耗尽
- [ ] 部署 WAF 规则检测 GraphQL 攻击特征
8.2 推荐的安全工具
| 工具 | 用途 | 语言/平台 |
|---|---|---|
| GraphQL Armor | 全面的 GraphQL 安全中间件,支持深度限制、复杂度分析、字段限制等 | Node.js |
| graphql-shield | 声明式权限控制中间件,保护 Resolver 层 | Node.js |
| graphql-depth-limit | 查询深度限制验证规则 | Node.js |
| graphql-query-complexity | 查询复杂度分析和限流 | Node.js |
| graphql-constraint-directive | 输入参数校验指令 | Node.js |
| inigo | GraphQL 安全监控和运行时保护 | SaaS |
| Escape | GraphQL API 安全扫描服务 | SaaS |
8.3 GraphQL Armor 集成示例
GraphQL Armor 是目前功能较为全面的 GraphQL 安全库,集成了多项防护能力:
const { ApolloServer } = require('apollo-server-express');
const { armor } = require('graphql-armor');
const server = new ApolloServer({
schema,
plugins: [
armor({
// 最大深度限制
maxDepth: {
n: 6,
flattenFragments: true,
},
// 最多别名数量
maxAliases: {
n: 30,
},
// 字段数量限制
maxFields: {
n: 100,
},
// 禁用 Introspection
disableIntrospection: {
isDev: process.env.NODE_ENV !== 'production',
},
// 请求体大小限制
maxDirectives: {
n: 20,
},
// 令牌桶限流
costLimit: {
maxCost: 500,
},
// 字符黑名单过滤
characterBlacklist: {
blacklist: ['\r', '\n', '\t'],
},
}),
],
});8.4 持久化查询(Persisted Queries)安全方案
持久化查询是一种将预定义的查询白名单存储于服务端的机制,可以有效防止任意查询攻击:
// 服务端:注册持久化查询
const persistedQueries = {
'e7a8b9c0': `
query GetUser($id: ID!) {
user(id: $id) {
name
email
}
}
`,
'f1d2e3f4': `
query GetPosts($page: Int) {
posts(page: $page) {
title
createdAt
}
}
`,
};
// 中间件:仅允许持久化查询
async function persistedQueryOnly(req, res, next) {
const { hash, variables } = req.body;
if (!hash || !persistedQueries[hash]) {
return res.status(400).json({
error: '未授权的查询',
message: '仅允许使用预定义的持久化查询',
});
}
req.body.query = persistedQueries[hash];
req.body.variables = variables || {};
next();
}九、总结
GraphQL 的安全防护不能简单套用 REST API 的安全模式。其灵活的数据查询能力在提升开发效率的同时,也引入了全新的攻击向量。核心防御原则可概括为以下四点:
- 减少攻击面:生产环境禁用 Introspection,实施持久化查询白名单
- 限制资源消耗:通过深度限制、别名限制、复杂度分析防止资源耗尽
- 逐层授权:在 Resolver 和 DataLoader 中逐字段实施权限校验,不依赖客户端过滤
- 按需限流:以查询复杂度为计量单位进行速率限制,而非简单的请求次数
在工程实践中,建议将 GraphQL Armor(防护中间件)和 graphql-shield(权限控制)结合使用,配合全面的日志监控和异常告警,构建从接入层到数据层的纵深防御体系。