GraphQL 持久化查询与生产安全:从 APQ 到白名单的完整方案

GraphQL 持久化查询(APQ)与安全实践:Automatic Persisted Queries 原理、查询白名单、操作哈希、深度/复杂度限制、CSRF 防护与 introspection 控制、Apollo 客户端集成、批量查询限制与生产安全基线。

GraphQL 的即席查询(ad-hoc query)能力是双刃剑:它让开发者灵活取数,也让攻击者可以构造任意查询。**持久化查询(Persisted Queries)**通过"把查询固化在服务端、客户端只发送哈希"的方式,从根上收紧了 GraphQL 的攻击面。本文先讲清 APQ(Automatic Persisted Queries)的原理,再给出从白名单、操作哈希、复杂度强化到 CSRF 防护的完整生产安全方案,并附 Apollo 生态的落地集成方式。

一、APQ 原理与实现

1.1 普通查询的流程

客户端每次请求都把完整查询文本发给服务端:

POST /graphql
{
  "query": "query Feed { feed(first: 20) { edges { node { id title } } } }",
  "variables": { "first": 20 }
}

问题:查询文本每次都要传输、解析、校验。高频接口下,解析与校验成本占了服务端 CPU 的相当比例。

1.2 APQ 的两段式流程

APQ(Automatic Persisted Queries,由 Apollo 提出)让客户端先发哈希,命中缓存则直接执行:

第一次(哈希未命中):
  客户端 → { "extensions": { "persistedQuery": { "version": 1, "sha256Hash": "abc..." } } }
  服务端返回 PersistedQueryNotFound

第二次(带查询文本注册):
  客户端 → { "query": "...完整查询...", "extensions": { "persistedQuery": { "version": 1, "sha256Hash": "abc..." } } }
  服务端缓存哈希→查询文本,并执行

后续(命中缓存):
  客户端 → { "extensions": { "persistedQuery": { "version": 1, "sha256Hash": "abc..." } } }
  服务端直接按哈希执行

1.3 APQ 的收益

收益说明
减少传输请求体从 KB 级降到几十字节
减少解析服务端可跳过重复解析与校验
天然缓存键哈希本身就是稳定的缓存 key
安全基础为白名单机制提供"查询指纹"

APQ 的哈希是查询文本的 SHA-256 摘要:sha256 = SHA256(规范化查询文本)。客户端与服务端必须对同一文本算出同一哈希,因此变量化、注释去除等规范化必须一致(Apollo 客户端与 @apollo/server 已内置)。


二、持久化查询白名单(Persisted Queries List)

2.1 从 APQ 到强制白名单

APQ 解决的是传输与解析效率;**白名单(persisted queries list)**解决的是"只允许已注册的查询执行"。二者的关系:

模式行为安全性
纯 APQ任意查询都可注册并执行仅优化,不设限
白名单 APQ只允许注册表中存在的哈希拒绝一切未注册查询
混合模式白名单 + 按需审批严格且灵活

Apollo Server 的白名单实现方式:

// 服务端维护一个已知哈希集合
const persistedQueries = new Set<string>([
  'sha256hex1',
  'sha256hex2',
  // ...
]);

// 插件:只允许白名单内的哈希执行
import { ApolloServerPluginPersistedQueries } from '@apollo/server/plugin/persistedQueries';

const server = new ApolloServer({
  plugins: [
    ApolloServerPluginPersistedQueries({
      // 自定义"能否注册"的决策:不在白名单则直接拒绝
      async shouldPersist(requestContext) {
        const hash = requestContext.request.extensions?.persistedQuery?.sha256Hash;
        return persistedQueries.has(hash);
      },
    }),
  ],
});

2.2 白名单的注册流程

白名单必须来自受控的注册通道,而不是让客户端随手注册:

# CI 中生成并注册持久化查询清单
npx persistgraphql --input ./src/**/*.graphql \
  --output ./persisted-query-whitelist.json

# 将 whitelist 文件上传到服务端/注册表
curl -X POST https://api.example.com/graphql/persist \
  -H 'authorization: Bearer $DEPLOY_TOKEN' \
  --data-binary @./persisted-query-whitelist.json

persistgraphql(Apollo 官方)会把代码库中所有 operation 文本转成哈希列表,与代码一起走发布流程。

2.3 白名单的治理挑战

白名单机制的痛点在于治理:

  • 前端改动查询 → 哈希变化 → 必须重新注册,否则线上报错;
  • 多团队并行时,白名单更新容易冲突;
  • 后端无法看到"查询意图",只看到哈希。

对策是把白名单注册嵌入 CI(前端 PR 合并即自动注册),并让白名单文件可 diff、可回滚。这也是为什么很多团队最终选择 GraphQL Hive 或 Apollo GraphOS 这样的托管注册表。


三、操作哈希与签名

3.1 为什么需要"签名"而非裸哈希

APQ 的 SHA-256 只证明"查询文本一致",不证明"这个查询来自可信客户端"。攻击者可以拿到客户端公网 App 里的任何哈希,照样发起请求。所以真正严格的安全基线需要对查询签名。

3.2 HMAC 签名方案

让客户端在构建时对查询文本计算 HMAC(密钥仅存在于可信构建环境),服务端用同一密钥校验:

// 构建期:为每个 operation 生成签名
import { createHmac } from 'node:crypto';

export function signOperation(query: string): string {
  return createHmac('sha256', process.env.QUERY_SIGN_KEY!)
    .update(query)
    .digest('hex');
}

请求携带签名,服务端校验:

// 服务端插件:校验签名
function verifySignature(query: string, signature: string): boolean {
  const expected = createHmac('sha256', process.env.QUERY_SIGN_KEY!)
    .update(query)
    .digest('hex');
  return timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}

3.3 签名 vs 哈希的分工

机制证明什么防什么密钥要求
SHA-256 哈希(APQ)文本一致传输冗余、缓存复用无
HMAC 签名文本来自可信构建未授权查询执行有(构建侧保密)
哈希 + 白名单查询在允许列表内未注册查询无(列表即授权)

原则:APQ 哈希负责"去重",白名单负责"授权",HMAC 签名负责"防伪造"。三者组合才是完整的持久化查询安全栈。


四、深度/复杂度限制强化

4.1 持久化查询下的限制特殊性

有了白名单,任意深查询已被拒绝。但白名单内的合法查询也可能过深过贵(业务写歪了),仍需深度与复杂度限制兜底。持久化查询的独特之处在于:服务端可以对白名单查询做"预计算成本"。

4.2 预计算成本 + 运行时拦截

// 构建期:对每个白名单 operation 预计算复杂度
interface WhitelistedOperation {
  hash: string;
  complexity: number;
  depth: number;
}

// 运行时:直接按预计算值拦截,无需逐次遍历 AST
function checkPrecomputedCost(hash: string): boolean {
  const op = whitelist.get(hash);
  return op && op.complexity <= MAX_COMPLEXITY && op.depth <= MAX_DEPTH;
}

这比每次请求遍历 AST 做复杂度评估高效得多——持久化查询把成本评估从运行时挪到了构建期。

4.3 限制参数的建议值

参数建议说明
maxDepth10白名单内也应有深度上限
maxComplexity1000–2000依据压测校准
maxAliases30防别名放大
maxRootFields5控制根并发
单 operation 最大列表倍数100first: 100 类参数上限

五、CSRF 防护与 introspection 控制

5.1 GraphQL 的 CSRF 面

GraphQL 端点若接受 application/json POST,且认证基于 Cookie,就存在 CSRF(跨站请求伪造) 风险:恶意站点可让受害浏览器向你的 /graphql 发带 Cookie 的 JSON POST。防护三板斧:

防护做法
Content-Type 校验拒绝简单媒体类型(text/plain、application/x-www-form-urlencoded)发起的非 GET 请求
SameSite CookieSameSite=Lax/Strict
自定义 Header要求请求携带 x-apollo-operation-name 或 x-csrf-token 等非简单头
// Apollo Server 4 自带 CSRF 防护:默认拒绝非标准 media type
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';

app.use(
  '/graphql',
  expressMiddleware(server, {
    // 额外校验自定义头,进一步加固
  }),
);

5.2 introspection 控制

introspection(内省) 让任何人都能拉取完整 Schema,是信息收集的利器。生产环境应分级控制:

环境introspection说明
本地/开发开启开发体验
预发可开启(受认证保护)联调需要
生产关闭或白名单开启防 Schema 泄露
生产(内部工具)按认证开放需登录的内网管理端
const server = new ApolloServer({
  typeDefs,
  resolvers,
  introspection: process.env.NODE_ENV !== 'production',
});

注意:完全关闭 introspection 会伤及内部调试与 GraphQL 生态工具(Playground 依赖它)。更精细的做法是按请求者授权:认证用户可见 introspection,匿名不可见。

5.3 关闭 introspection 的副作用

  • Apollo Client DevTools、Playground 无法使用 → 内部工具改用 GraphQL Mesh/Hive 的 registry;
  • 自动化文档生成失效 → 用 Schema Registry 代替;
  • 排查"这个字段存不存在"变难 → 依赖代码库 SDL。

决策建议:默认生产关闭 introspection,通过中间件对内部 IP / 认证用户按需开放。不要一刀切到"谁都不能看",否则运维排障成本会上升。


六、客户端(Apollo)集成

6.1 Apollo Client 启用 APQ

Apollo Client 内置 createPersistedQueryLink,一行开启 APQ:

import { ApolloClient, InMemoryCache } from '@apollo/client';
import { createPersistedQueryLink } from '@apollo/client/link/persisted-queries';
import { createHttpLink } from '@apollo/client/link/http';
import { getHash } from '@apollo/client/link/persisted-queries';

const persistedLink = createPersistedQueryLink({
  // 指定哈希算法(必须与服务端一致)
  sha256: getHash,
  // 注册失败时自动降级为普通查询
  useGETForHashedQueries: true,
});

const httpLink = createHttpLink({ uri: '/graphql' });

export const client = new ApolloClient({
  cache: new InMemoryCache(),
  link: persistedLink.concat(httpLink),
});

6.2 与服务端插件配对

客户端开启 APQ 后,服务端必须安装对应插件,否则会收到大量 PersistedQueryNotFound:

import { ApolloServerPluginPersistedQueries } from '@apollo/server/plugin/persistedQueries';

const server = new ApolloServer({
  plugins: [
    ApolloServerPluginPersistedQueries({
      ttl: 86400, // 缓存 1 天
      cache: new InMemoryLRUCache({ maxSize: 1_000_000 }), // 可替换为 Redis
    }),
  ],
});

6.3 降级与容错

场景客户端行为服务端行为
服务端重启缓存清空自动重新注册(带 query 重发)重新缓存
服务端不支持 APQ自动降级为完整查询正常执行
哈希不一致收到 not found 后重发完整查询校验哈希是否匹配
网络中断重试—

Apollo 客户端在收到 PersistedQueryNotFound 时会自动附带完整查询重发,因此APQ 是"渐进增强":服务端升级后无需改动客户端即可享受优化。


七、批量查询与 batching 限制

7.1 请求 batching(HTTP 层)

Apollo Server 支持在一个 HTTP 请求中发送多个 operation([query1, query2]):

[
  { "query": "query A { ... }" },
  { "query": "query B { ... }" }
]

batching 降低了网络往返,但也放大了一次请求的计算量,且与 CSRF 防护存在交互(Apollo Server 4 已默认禁用 batching)。生产建议:

决策说明
默认关闭Apollo Server 4 已默认拒绝数组请求
按需开放仅对可信内部客户端开放
严格限额单请求 operation 数 ≤ 10,且逐条做复杂度限制
监控对 batching 请求单独统计与限流

7.2 mutation batching 的风险

多个 mutation 在一个请求中并发执行,若其中部分失败,事务边界与部分成功语义难以界定。GraphQL 规范不保证 mutation 串行执行(服务端实现各异)。强烈建议:HTTP batching 仅允许 query,mutation 一律单发。

7.3 @defer / @stream 的注意点

持久化查询与 @defer/@stream(增量交付)叠加时,哈希应基于不含增量指令的规范化文本还是含指令文本,各实现有分歧。生产落地时需先在目标运行时验证哈希一致性,避免增量查询全部 miss。


八、生产安全基线

8.1 一份可落地的安全基线清单

以下是 GraphQL 生产服务的最小安全基线,建议逐项对照:

  • 生产环境关闭 introspection(或按认证开放);
  • 启用 APQ + 白名单,拒绝未注册查询;
  • 深度限制(≤ 10)+ 复杂度限制(校准值);
  • CSRF 三板斧(Content-Type 校验 + SameSite + 自定义头);
  • HTTP batching 关闭或严格限额;
  • 认证授权前置(网关或中间件),不裸奔到 resolver;
  • 错误脱敏(formatError 剥离堆栈与内部信息);
  • 速率限制(token bucket / Redis 计数);
  • 请求体大小上限(如 1MB);
  • 审计日志(operationName、hash、userId、时间戳)。

8.2 各防护层的职责分工

层防护拦截对象
网关/CDN速率限制、WAF、请求体上限流量攻击
HTTP 中间件CSRF、认证、introspection 控制未授权访问
解析层深度/复杂度限制、batching 限制昂贵查询
执行层白名单、签名校验未注册/伪造查询
业务层授权、字段级权限越权读取
输出层错误脱敏、审计日志信息泄露

8.3 安全基线的量化指标

指标建议
白名单覆盖率生产 operation 100% 在白名单
introspection 生产开启率0(除非授权开放)
复杂度超限拦截率应为 0(超限即代表白名单有漏网)
未注册查询拦截数应有告警(有人绕过客户端直接打接口)
CSRF 校验失败数应趋近于 0

九、综合实践

9.1 一套完整的持久化查询落地流程

  1. 开发期:正常开发,不做任何持久化改造;
  2. 构建期:persistgraphql 扫描全部 operation,生成哈希清单 + 签名;
  3. CI 校验:清单 diff、签名校验、复杂度预计算全部通过才可合并;
  4. 发布期:将清单上传到服务端注册表(随服务部署);
  5. 运行时:客户端先发哈希,服务端命中白名单 + 预计算成本通过则执行;
  6. 监控:未注册查询、超限复杂度、签名失败均告警。

9.2 演进路径:三阶段实施

阶段改造效果
阶段一开启 APQ(无白名单)传输与解析优化
阶段二加入白名单 + introspection 控制拒绝未注册查询
阶段三签名校验 + 预计算成本 + CSRF 加固全栈安全

每个阶段都可独立上线、独立回滚,避免一次性大改造带来的风险。

9.3 常见妥协与代价

  • 纯白名单会限制 GraphQL 的即席查询灵活性:临时排查、SQL 调试式查询会被拒。对策是保留一个内部白名单通道(如管理员可注册临时查询,限 TTL)。
  • 签名密钥管理:构建侧密钥必须保密,泄露等于白名单形同虚设。用 CI Secret 管理,定期轮换。
  • 哈希规范化歧义:不同客户端库对查询文本的规范化不一致会导致哈希漂移。统一客户端库版本,或在构建期统一生成。

FAQ

Q1: APQ 和白名单是一回事吗?

不是。APQ 是"用哈希代替查询文本传输"的效率机制;白名单是"只允许已注册哈希执行"的安全机制。可以只开 APQ 不做白名单(仅优化),也可以两者叠加(优化 + 安全)。

Q2: 白名单会完全禁止即席查询吗?

取决于模式。严格白名单会拒绝一切未注册查询;混合模式可保留受控的即席通道(如管理员 TTL 注册)。对面向外部用户的公开 GraphQL,建议严格白名单;对内 BFF 可混合。

Q3: 关闭 introspection 后团队如何调试 Schema?

用 Schema Registry(Apollo GraphOS / Hive)替代 introspection 作为 Schema 消费源;内部工具(需认证)可在中间件层对登录用户开放 introspection。完全关闭只防匿名,不防有权限的内部人员。

Q4: HTTP batching 为什么默认禁用?

batching 放大单次请求计算量、与 CSRF 防护存在交互、且 mutation 并发导致部分成功语义混乱。Apollo Server 4 已默认拒绝数组请求。需要时对可信客户端限额开放。

Q5: 哈希规范化不一致会导致什么?

客户端算出哈希 A、服务端按自己的规范化算出哈希 B,双方对不上 → 每次请求都走"not found + 完整查询重发"路径,APQ 完全失效且多一次往返。解决:统一客户端库版本 + 构建期统一生成哈希 + 服务端插件选用与客户端一致的哈希算法。


一句话总结

持久化查询把 GraphQL 的"任意即席查询"收紧为"受控的注册查询":APQ 负责传输与解析优化,白名单与 HMAC 签名负责授权与防伪造,预计算成本与深度限制兜底昂贵查询,再叠加 introspection 控制与 CSRF 防护——它是 GraphQL 生产安全中最系统、最可量化的一套基线。


相关阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「GraphQL」更多文章

  1. GraphQL Mutation 设计实战:从语义命名到乐观更新
  2. 游标分页与中继连接:从 offset 到 cursor 的工程实践
  3. GraphQL 错误处理与可观测性:从 errors[] 到链路追踪