一句话总结:GraphQL 的灵活性使攻击面扩大——通过深度查询限制、复杂度分析、认证授权层和输入校验,可建立纵深防御体系。
1. GraphQL 特有的安全威胁
GraphQL 的设计哲学(单一端点、自省能力、查询灵活性)在带来便利的同时也引入了独特的安全风险。
1.1 GraphQL vs REST 攻击面差异
| 威胁 | REST | GraphQL | 说明 |
|---|---|---|---|
| 端点探测 | 多 URL 可枚举 | 单一 /graphql 端点 | GraphQL 端点隐蔽性低 |
| 数据获取控制 | 服务端决定返回字段 | 客户端决定返回字段 | 过度获取风险转移至恶意查询 |
| Schema 发现 | 需猜测端点结构 | 内省暴露完整 Schema | 攻击者可发现所有可用字段 |
| DoS 攻击 | 特定 URL 高频请求 | 复杂递归查询 | 单次查询即可耗尽服务器资源 |
| 认证差异 | 按 URL/Method 鉴权 | 按字段鉴权 | 需更细粒度的访问控制 |
1.2 攻击面放大原理
# 一个简单的递归查询即可导致灾难
query Attack {
user(id: "1") {
friends {
friends {
friends {
friends {
friends {
friends {
friends {
friends {
# ... 无限嵌套
id
}
}
}
}
}
}
}
}
}
}
此查询的嵌套深度为 8,每层 friends 可能返回 50 个结果:
- 第 1 层:1 × 50 = 50 个对象
- 第 2 层:50 × 50 = 2,500 个对象
- 第 3 层:2,500 × 50 = 125,000 个对象
- …
- 第 8 层:约 3.9 × 10¹³ 个对象
即使数据库有外键限制,解析器的递归调用也会耗尽 CPU 和内存。
2. 深度查询与复杂度炸弹攻击
2.1 深度限制(Depth Limiting)
import { ApolloServer } from "@apollo/server";
import depthLimit from "graphql-depth-limit";
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
depthLimit(10, {
ignore: ["_entities", "_service"], // Federation 特殊字段
}),
],
});
深度计算方式:从根查询字段开始,每进入一层 SelectionSet,深度 +1。
# 深度 = 4
query {
user { # 0
posts { # 1
author { # 2
name # 3
}
}
}
}
2.2 节点数量限制
import { createComplexityLimitRule } from "graphql-validation-complexity";
const server = new ApolloServer({
validationRules: [
createComplexityLimitRule(1000, {
scalarCost: 1,
objectCost: 10,
listFactor: 20,
}),
],
});
2.3 查询超时
// Apollo Server 插件:查询超时
const timeoutPlugin = {
async requestDidStart() {
const timeoutMs = 5000;
const timeout = setTimeout(() => {
throw new Error("Query timeout exceeded");
}, timeoutMs);
return {
async willSendResponse() {
clearTimeout(timeout);
},
async didEncounterErrors() {
clearTimeout(timeout);
},
};
},
};
3. 错误信息泄漏防护
3.1 GraphQL 错误格式的安全风险
GraphQL 的错误响应默认包含丰富的信息,可能泄漏:
- 数据库表名和结构
- 内部服务地址
- 文件名和行号
- SQL 语句片段
{
"errors": [
{
"message": "Cannot read property 'name' of undefined",
"locations": [{ "line": 4, "column": 7 }],
"path": ["user", "posts", 0, "author"],
"extensions": {
"code": "INTERNAL_SERVER_ERROR",
"exception": {
"stacktrace": [
"Error: Cannot read property 'name' of undefined",
" at /app/src/resolvers.js:45:23", // ⚠️ 泄漏路径
" at /app/node_modules/prisma-client/index.js:890:12" // ⚠️ 泄漏依赖
]
}
}
}
]
}
3.2 生产环境错误过滤
import { ApolloServer } from "@apollo/server";
const server = new ApolloServer({
typeDefs,
resolvers,
formatError: (error) => {
// 生产环境过滤敏感信息
if (process.env.NODE_ENV === "production") {
return {
message: "Internal server error",
extensions: {
code: "INTERNAL_SERVER_ERROR",
},
};
}
// 开发环境保留完整信息
return error;
},
});
3.3 分类错误策略
class GraphQLError extends Error {
constructor(
message: string,
public code: string,
public isPublic: boolean = false
) {
super(message);
}
}
class UserNotFoundError extends GraphQLError {
constructor(userId: string) {
super(`User ${userId} not found`, "USER_NOT_FOUND", true);
}
}
class DatabaseError extends GraphQLError {
constructor() {
super("Database connection failed", "DB_ERROR", false); // 不暴露
}
}
// formatError
formatError: (error) => {
const original = error.originalError as GraphQLError;
if (original?.isPublic) {
return { message: original.message, code: original.code };
}
return { message: "Internal error", code: "INTERNAL_ERROR" };
},
4. 认证与授权
4.1 JWT 认证注入 Context
import { GraphQLError } from "graphql";
import { verify } from "jsonwebtoken";
interface Context {
user?: { id: string; role: string };
}
const server = new ApolloServer({
typeDefs,
resolvers,
context: async ({ req }): Promise<Context> => {
const token = req.headers.authorization?.replace("Bearer ", "");
if (!token) return { user: undefined };
try {
const payload = verify(token, process.env.JWT_SECRET!) as any;
return { user: { id: payload.sub, role: payload.role } };
} catch {
throw new GraphQLError("Invalid token", {
extensions: { code: "UNAUTHENTICATED" },
});
}
},
});
4.2 字段级授权
方案一:Shield 中间件
import { shield, rule, allow } from "graphql-shield";
const isAuthenticated = rule()(async (_parent, _args, ctx: Context) => {
return ctx.user !== undefined;
});
const isAdmin = rule()(async (_parent, _args, ctx: Context) => {
return ctx.user?.role === "ADMIN";
});
const permissions = shield({
Query: {
me: isAuthenticated,
users: isAdmin,
user: allow, // 公开
},
Mutation: {
deleteUser: isAdmin,
updateProfile: isAuthenticated,
},
User: {
email: isAuthenticated, // 仅登录用户可见邮箱
role: isAdmin, // 仅管理员可见角色
},
});
方案二:Pothos 授权插件
import { builder } from "@pothos/core";
import AuthPlugin from "@pothos/plugin-authz";
builder.use(AuthPlugin);
builder.queryType({
fields: (t) => ({
me: t.field({
type: "User",
authz: {
rules: ["IsAuthenticated"],
},
resolve: (_parent, _args, ctx) => ctx.user,
}),
}),
});
4.3 RBAC vs ABAC
| 模型 | 依据 | 示例 | 适用场景 |
|---|---|---|---|
| RBAC | 角色 | role === "ADMIN" | 简单权限体系 |
| ABAC | 多属性组合 | user.id === resource.ownerId && resource.status !== "LOCKED" | 复杂业务权限 |
// ABAC 在 Resolver 中的实现
async resolve(parent, args, ctx) {
const post = await db.post.findUnique({ where: { id: args.id } });
// 仅允许作者或管理员编辑
if (post.authorId !== ctx.user.id && ctx.user.role !== "ADMIN") {
throw new GraphQLError("Forbidden", {
extensions: { code: "FORBIDDEN" },
});
}
return post;
}
5. 输入校验与注入防护
5.1 Zod 校验 GraphQL Args
import { z } from "zod";
const CreatePostInput = z.object({
title: z.string().min(1).max(200),
content: z.string().max(50000),
tags: z.array(z.string().max(50)).max(10),
published: z.boolean().default(false),
});
// Resolver 中使用
type CreatePostArgs = z.infer<typeof CreatePostInput>;
const resolvers = {
Mutation: {
createPost: async (_: any, args: CreatePostArgs, ctx: Context) => {
const validated = CreatePostInput.parse(args.input);
// validated 类型安全
return db.post.create({ data: validated });
},
},
};
5.2 SQL 注入穿越 Resolver
GraphQL 本身不防 SQL 注入——如果 Resolver 中使用字符串拼接构造 SQL:
// ❌ 危险:字符串拼接
async resolve(_, { id }) {
const query = `SELECT * FROM users WHERE id = '${id}'`; // 注入风险
return db.$queryRaw(query);
}
// ✅ 安全:参数化查询 / ORM
async resolve(_, { id }) {
return db.user.findUnique({ where: { id } }); // Prisma 自动参数化
}
5.3 NoSQL 注入
MongoDB 等 NoSQL 数据库同样存在注入风险:
// ❌ 危险:直接传入未校验的对象
async resolve(_, { filter }) {
return db.users.find(filter); // filter 可能包含 {$ne: null}
}
// ✅ 安全:白名单过滤 + 强制类型
async resolve(_, { filter }) {
const safeFilter = {
name: String(filter.name || ""),
age: filter.age ? parseInt(filter.age) : undefined,
};
return db.users.find(safeFilter);
}
6. GraphQL Armor 与 Apollo Shield 工具链
6.1 GraphQL Armor(推荐)
GraphQL Armor 是即插即用的安全插件集,集成 Apollo Server:
import { ApolloServer } from "@apollo/server";
import { ApolloArmor } from "@escape.tech/graphql-armor";
const armor = new ApolloArmor({
costLimit: {
enabled: true,
maxCost: 5000,
objectCost: 2,
scalarCost: 1,
depthCostFactor: 1.5,
maxDepth: 10,
},
maxAliases: {
enabled: true,
n: 5,
},
maxDirectives: {
enabled: true,
n: 10,
},
maxTokens: {
enabled: true,
n: 2000,
},
blockFieldSuggestion: {
enabled: true, // 生产环境关闭 "Did you mean?" 提示,防止 Schema 探测
},
});
const server = new ApolloServer({
typeDefs,
resolvers,
...armor.protect(),
});
6.2 规则详解
| Armor 规则 | 防御攻击 | 推荐值 |
|---|---|---|
costLimit | 复杂度炸弹 | maxCost: 5000, maxDepth: 10 |
maxAliases | 别名放大(用别名发起多个相同查询) | n: 5 |
maxDirectives | 指令滥用 | n: 10 |
maxTokens | 超大查询解析 | n: 2000 |
blockFieldSuggestion | 字段探测(Did you mean?) | 生产启用 |
6.3 Apollo Server 自带防护
const server = new ApolloServer({
typeDefs,
resolvers,
introspection: process.env.NODE_ENV !== "production", // 生产关闭内省
persistedQueries: {
cache: new Map(),
ttl: 900,
},
});
7. Introspection 安全
7.1 生产环境关闭内省
Schema 内省允许任何客户端查询完整的 Schema 结构,是信息泄漏的主要来源。
const server = new ApolloServer({
typeDefs,
resolvers,
introspection: false, // 生产环境关闭
plugins: [
{
async requestDidStart() {
return {
async didResolveOperation({ request, document }) {
// 额外防护:检测并拒绝 __schema / __type 查询
const hasIntrospection = document.definitions.some((def) =>
def.kind === "OperationDefinition" &&
def.selectionSet.selections.some(
(s) => s.kind === "Field" && (s.name.value === "__schema" || s.name.value === "__type")
)
);
if (hasIntrospection && process.env.NODE_ENV === "production") {
throw new GraphQLError("Introspection is disabled");
}
},
};
},
},
],
});
7.2 替代内视的方法
- 文档优先:维护外部 API 文档(如 Docusaurus / Mintlify)
- SDK 分发:仅向已认证的合作伙伴提供 SDK
- 代码生成 CI:本地开发时开启内视,生成的类型文件提交到版本控制
8. 日志与监控
8.1 查询审计日志
const auditPlugin = {
async requestDidStart({ request, context }) {
const startTime = Date.now();
return {
async willSendResponse({ response, operation }) {
const duration = Date.now() - startTime;
console.log(JSON.stringify({
timestamp: new Date().toISOString(),
userId: context.user?.id,
operation: operation?.name,
query: request.query?.substring(0, 200), // 截断
duration,
status: response.http?.status,
}));
},
};
},
};
8.2 异常查询告警
| 告警条件 | 说明 |
|---|---|
| 复杂度 > 5000 | 疑似攻击查询 |
| 深度 > 10 | 深度查询攻击 |
| 单个 IP 请求 > 100/min | 高频请求 |
| 错误率 > 10% | 系统异常或探测 |
| 响应时间 P99 > 2s | 性能退化 |
9. 一句话总结
- 深度查询:限制
maxDepth=10防止递归耗尽资源 - 复杂度炸弹:
costLimit+maxAliases+maxTokens多层防护 - 错误泄漏:生产环境过滤 stacktrace 和内部路径
- 认证授权:JWT → Context → Shield / Pothos Authorization 逐层加固
- 输入防护:Zod 参数校验 + ORM 参数化查询 防 SQL/NoSQL 注入
- 内省安全:生产关闭 introspection,改用文档 + SDK 分发
- 工具链:GraphQL Armor 一站式防护 + Apollo Server 原生配置
FAQ
Q1:GraphQL Armor 会影响正常查询性能吗?
A:Armor 的校验在查询解析阶段执行,复杂度为 O(n),对于正常查询(< 200 tokens)的开销 < 1ms。生产环境实测 Armor 的解析开销 < 总查询时间的 0.5%。
Q2:是否应该完全禁止内省?
A:生产环境建议关闭内省。但如果有外部合作伙伴需要 Schema 信息,可以:① 提供脱敏后的 Schema 文档;② 使用白名单内省(仅对认证客户端开放);③ 分发生成好的 SDK。
Q3:字段级授权的性能开销大吗?
A:Shield 中间件在每个字段解析时执行规则检查。对于简单规则(如 isAuthenticated),开销极小(< 0.1ms)。复杂 ABAC 规则应缓存权限结果,避免重复计算。
Q4:GraphQL 查询日志会不会泄漏敏感数据?
A:是的。查询日志中可能包含密码、Token 等敏感信息。建议:① 配置 query 字符串长度截断;② 变量中的敏感字段使用 [REDACTED] 替换;③ 使用结构化日志而非直接打印。
Q5:如何处理文件上传的安全?
A:GraphQL Multipart 规范允许文件上传,需额外防护:① 文件大小限制;② MIME 类型白名单;③ 病毒扫描;④ 存储隔离;⑤ 文件名 sanitize(防止路径遍历)。使用 graphql-upload 包时,在 Apollo Server 外增设反向代理的 body 大小限制。
相关阅读
- GraphQL 基础与 Schema First 设计 — Resolver 与类型系统基础
- API 缓存与性能优化 — 复杂度分析与限流
- API 契约测试 — Schema 校验与 CI 安全门禁
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。