使用GraphQL,你可以随时精确查询任何你想要的内容。对于API来说,这是令人惊奇的,但是也有复杂的安全隐患。恶意人员可能提交一个开销大的嵌套的查询,而不是请求合法的有用数据,来使你的服务器、数据库、网络或所有这些设施过载。没有正确的保护措施,你就会面临拒绝服务(Denial of Service,DoS)攻击的风险。
例如,在我们Spectrum的GraphQL API中,我们有一个如下关系:
type Thread {
messages(first: Int, after: String): [Message]
}
type Message {
thread: Thread
}
type Query {
thread(id: ID!): Thread
}
如你所见,你既可以查询一个线程的消息列表,也能查询一个消息的线程。这种循环关系让恶意人员可以构建一个如下的开销大的嵌套循环:
query maliciousQuery {
thread(id: "some-id") {
messages(first: 99999) {
thread {
messages(first: 99999) {
thread {
messages(first: 99999) {
thread {
# ...repeat times 10000...
}
}
}
}
}
}
}
}
如果让这种查询通过,那后果是非常糟糕的,因为它会指数级增加加载的对象数量并使你的整个服务器崩溃。尽管在其它层有一些缓解措施可以使得在第一时间发送这种查询有点儿困难(例如,CORS),但它们不能完全阻止这类查询的发生。
我们考虑的第一种“天真”方案是根据原始字节数限制传入的查询大小。由于查询以字符串形式发送,因此一个快速的长度检查就足够了:
app.use('*', (req, res, next) => {
const query = req.query.query || req.body.query || '';
if (query.length > 2000) {
throw new Error('Query too large');
}
next();
});
不幸的是,这在现实世界不怎么生效:这个检查可能会让使用短字节名的恶意查询通过,阻止使用长字节名或嵌套结构的合法查询。
我们考虑的第二种方案是配置一个在我们自己的应用程序中认可的查询的白名单,告诉服务器不要让这些查询之外的任何查询通过。
app.use('/api', graphqlServer((req, res) => {
const query = req.query.query || req.body.query;
// TODO: Get whitelist somehow
if (!whitelist[query]) {
throw new Error('Query is not in whitelist.');
}
/* ... */
}));
手动维护这个认可的查询列表显示是一件痛苦的事,但值得庆幸的是,Apollo 团队创建了persistgraphql ,它会从你的客户端代码中自动抽取所有查询并生成一个漂亮的 JSON 文件。
{
"scripts": {
"postbuild": "persistgraphql src api/query-whitelist.json"
}
}
这个技术很不错,能可靠地阻拦所有恶意查询。不幸的是,它也有两个主要权衡:
这些都是我们无法接受的约束,所以我们只能回到原来的处境。
上述恶意查询的一个有害方面就是嵌套,标志就是它的深度,这使得查询的开销呈现指数级增加。每一层都给你的后端增加了更多工作,当结合列表时可以快速增长。
我们环顾四周,发现了 graphql-depth-limit ,一个由 Andrew Carlson 开发的模块,让我们能轻易限制输入查询的最大深度。我们检查了客户端,发现使用的查询的最大深度为 7 层,因此我们设置最大深度为 10(相当宽大)并将它添加到我们的校验规则中:
app.use('/api', graphqlServer({
validationRules: [depthLimit(10)]
}));
深度限制就是这么简单!
上述查询的第二个有害方面是获取 99999 个对象。无论这个对象是什么,获取大量的这个对象都会是开销巨大的。(尽管数据库压力可以通过 DataLoader 缓解,但网络和进程压力不会)
与其设置第一个参数类型为 Int(接受任意数字),我们用 graphql-input-number 创建了一个自定义标量,限制最大值为 100::
const PaginationAmount = GraphQLInputInt({
name: 'PaginationAmount',
min: 1,
max: 100,
});
如果任何人查询超过 100 个对象,这就会抛出一个错误。我们然后在任何使用连接的 API 的地方使用这个设置:
type Thread { messages(first: PaginationAmount, after: String): [Message]}
现在,我们已经完全阻止了上述恶意查询!
不幸的是,在正确的情况下仍然有潜在的问题会使服务崩溃:有一些特定的 app 相关的查询,既不会太深,也不会请求太多对象,但是开销仍然会非常大。在 Spectrum,对我们来说,这样的查询可能是这样的:
query evilQuery {
thread(id: "54887141-57a9-4386-807c-ed950c4d5132") {
messageConnection(first: 100) { ... }
participants(first: 100) {
threadConnection(first: 100) { ... }
communityConnection { ... }
channelConnection { ... }
everything(first: 100) { ... }
}
}
}
深度或个体数量在这个查询中都不是特别高,因此它可以通过我们当前的保护措施。然而,它可能会获取成千上万条记录,这意味着它在数据库、服务器和网络上都是非常密集的,这是最坏的情况。 为阻止这种情况,我们需要在运行查询前对这些查询进行分析,计算它们的复杂度,如果它们的开销太大就阻止它们。这会比我们之前的保护措施更有效,能够 100% 确保没有恶意查询能够到达我们的解析器。
在花费大量时间实现查询成本分析前,最好确定你需要它。尝试用恶意查询让你当前的 API 崩溃或变慢,看看你能做到什么程度——也许你的 API 并没有这种嵌套关系,或者它可以很好地处理一次性获取数千条记录,根本不需要查询成本分析!
我在自己的 2017 MacBook Pro 上本地运行上述查询,我们的 API 服务器用了 10-15 秒来响应一个兆级 JSON 数据。我们确实需要查询成本分析,因为我们不希望任何人用那个查询来“轰炸”我们的 API。(GitHub GraphQL API 也使用了查询成本分析)
在 npm 上,有大量实现查询成本分析的包。我们的两个领先者是 graphql-validation-complexity ,一个即插即用模块,和 graphql-cost-analysis ,通过让你指定 @cost 指令来让你有更多控制能力。还有一个graphql-query-complexity ,但我不推荐选择这个而不是 graphql-cost-analysis,因为两者想法类似,但它没有指令和乘法器支持。
我们使用 graphql-cost-analysis ,因为我们最快的解析器(20μs)和最慢的解析器(10s+)之间有巨大差异,因此我们需要它提供的控制能力。换句话说,graphql-validation-complexity 对你来说已经足够了。
它工作的方式是,你指定解析一个特定字段或类型的相对成本。它还支持乘法,因此,如果你请求了一个列表,其中包含的任何嵌套字段都会乘以页数,这非常简洁。
@cost 指令实际上是这样的:
type Participant {
# The complexity of getting one thread in a thread connection is 3, and multiply that by the amount of threads fetched
threadConnection(first: PaginationAmount, after: String): ThreadConnection @cost(complexity: 3, multipliers: ["first"])
}
type Thread {
author: Author @cost(complexity: 1)
participants(first: PaginationAmount,...): [Participant] @cost(complexity: 2, multipliers: ["first"])
}
这只是我们的 API 类的一部分,但是你可以明白指令是怎么样的了。你指定某个特定字段的复杂度,用于相乘,以及最大成本,然后 graphql-cost-analysis 会为了完成其余工作。 我通过 Apollo Engine 披露的性能跟踪数据来决定特定解析器的复杂度。我浏览了整个 schema,并根据 p99 服务的时间分配了一个值。然后,我们遍历客户端上所有的查询来找出开销最大的查询,这个查询的复杂度得分大约有 500。为了给我们未来留一点余地,我们将最大复杂度设为 750。
既然我们已经添加了 graphql-cost-analysis,运行上面的恶意查询,我得到了一个错误信息,告诉我“GraphQL 查询超过了最大复杂度,请删除一些嵌套或字段之后再重试。(最大:750,实际:1010319)”
一百万复杂度得分?拒绝!
综上所述,我建议使用深度和数量限制作为任何 GraphQL API 的最小防护措施——它们易于实现而且能给予充足的安全保障。根据你具体的安全需求和架构,你可能需要研究查询成本分析。虽然它相比于其它工具需要更多工作,但它确实提供了针对恶意行为的全面防护。
原文链接:
https://www.apollographql.com/blog/securing-your-graphql-api-from-malicious-queries-16130a324a6b/
领取专属 10元无门槛券
私享最新 技术干货