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 限制参数的建议值
| 参数 | 建议 | 说明 |
|---|---|---|
| maxDepth | 10 | 白名单内也应有深度上限 |
| maxComplexity | 1000–2000 | 依据压测校准 |
| maxAliases | 30 | 防别名放大 |
| maxRootFields | 5 | 控制根并发 |
| 单 operation 最大列表倍数 | 100 | first: 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 Cookie | SameSite=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 一套完整的持久化查询落地流程
- 开发期:正常开发,不做任何持久化改造;
- 构建期:
persistgraphql扫描全部 operation,生成哈希清单 + 签名; - CI 校验:清单 diff、签名校验、复杂度预计算全部通过才可合并;
- 发布期:将清单上传到服务端注册表(随服务部署);
- 运行时:客户端先发哈希,服务端命中白名单 + 预计算成本通过则执行;
- 监控:未注册查询、超限复杂度、签名失败均告警。
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 生产安全中最系统、最可量化的一套基线。
相关阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。