GraphQL 安全防护:深度查询、认证授权与输入校验

GraphQL 特有安全威胁深度剖析:深度查询攻击(DoS)、复杂度炸弹、错误信息泄漏、未授权字段访问、SQL 注入穿越 Resolver。含 Apollo Shield / GraphQL Armor / Pothos 防护代码。

一句话总结:GraphQL 的灵活性使攻击面扩大——通过深度查询限制、复杂度分析、认证授权层和输入校验,可建立纵深防御体系。

1. GraphQL 特有的安全威胁

GraphQL 的设计哲学(单一端点、自省能力、查询灵活性)在带来便利的同时也引入了独特的安全风险。

1.1 GraphQL vs REST 攻击面差异

威胁RESTGraphQL说明
端点探测多 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」更多文章

  1. gRPC-Web 与 GraphQL 混合架构:微服务通信分层实战
  2. GraphQL 订阅、SSE 与 WebSocket 实时推送实战
  3. GraphQL 服务端实战:Apollo Server、GraphQL Yoga 与 Pothos 选型