PHP GraphQL 安全指南:深度限制、查询复杂度分析与输入校验实践
大家好,今天我们来深入探讨 PHP GraphQL API 的安全问题,以及如何通过深度限制、查询复杂度分析和输入校验等手段来保护我们的 GraphQL 服务。GraphQL 相比 REST API,暴露了更强大的查询能力,但也因此带来了新的安全风险,需要我们格外重视。
GraphQL 安全风险概述
GraphQL 的灵活性使其容易受到以下类型的攻击:
- 拒绝服务 (DoS) 攻击: 恶意用户构造复杂的查询,耗尽服务器资源。
- 信息泄露: 通过精心设计的查询,获取未经授权的数据。
- 身份验证和授权绕过: 攻击者可能绕过身份验证和授权机制,访问受保护的资源。
- GraphQL 注入: 类似于 SQL 注入,攻击者通过注入恶意 GraphQL 语法来执行未授权的操作。
深度限制 (Query Depth Limiting)
GraphQL 允许客户端请求嵌套很深的数据结构。如果不对查询深度进行限制,恶意用户可以构造深度嵌套的查询,导致服务器资源耗尽,造成 DoS 攻击。深度限制通过限制查询的最大嵌套层数来缓解这个问题。
实现原理:
在解析 GraphQL 查询之前,我们可以通过遍历查询树来计算其深度。如果查询的深度超过了预设的限制,则拒绝执行该查询。
代码示例:
<?php
use GraphQLLanguageParser;
use GraphQLLanguageASTDocumentNode;
use GraphQLLanguageASTSelectionSetNode;
/**
* 计算 GraphQL 查询深度的函数
*
* @param DocumentNode $queryAST GraphQL 查询的抽象语法树
* @param int $currentDepth 当前深度
* @return int 查询深度
*/
function calculateQueryDepth(DocumentNode $queryAST, int $currentDepth = 1): int
{
$maxDepth = $currentDepth;
foreach ($queryAST->definitions as $definition) {
if (property_exists($definition, 'selectionSet') && $definition->selectionSet instanceof SelectionSetNode) {
$selectionSet = $definition->selectionSet;
foreach ($selectionSet->selections as $selection) {
if (property_exists($selection, 'selectionSet') && $selection->selectionSet instanceof SelectionSetNode) {
$depth = calculateQueryDepth(new DocumentNode(['definitions' => [new GraphQLLanguageASTOperationDefinitionNode(['selectionSet' => $selection->selectionSet])]]), $currentDepth + 1);
$maxDepth = max($maxDepth, $depth);
}
}
}
}
return $maxDepth;
}
/**
* 检查查询深度是否超过限制
*
* @param string $query GraphQL 查询字符串
* @param int $maxDepth 允许的最大深度
* @return bool 是否超过限制
*/
function isQueryDepthExceeded(string $query, int $maxDepth): bool
{
try {
$queryAST = Parser::parse($query);
$depth = calculateQueryDepth($queryAST);
return $depth > $maxDepth;
} catch (Exception $e) {
// 解析错误,可以记录日志并拒绝查询
error_log("GraphQL query parsing error: " . $e->getMessage());
return true; // 视为超过限制,拒绝查询
}
}
// 示例用法
$query = '{
user {
id
name
posts {
id
title
comments {
id
text
author {
id
name
}
}
}
}
}';
$maxDepth = 4; // 允许的最大深度
if (isQueryDepthExceeded($query, $maxDepth)) {
http_response_code(400);
echo json_encode(['errors' => ['message' => 'Query depth exceeds the maximum allowed depth.']]);
} else {
// 执行查询
echo json_encode(['data' => ['message' => 'Query passed depth validation.']]); // 模拟执行成功
}
?>
代码解释:
calculateQueryDepth()函数递归地遍历 GraphQL 查询的抽象语法树 (AST),计算查询的深度。isQueryDepthExceeded()函数使用GraphQLLanguageParser解析查询字符串,然后调用calculateQueryDepth()计算深度,最后判断是否超过了预设的最大深度。- 在实际应用中,
$maxDepth的值应根据你的 API 的具体情况进行调整。 - 如果查询深度超过限制,返回 400 错误并给出相应的错误信息。
优点:
- 简单有效,易于实现。
- 能够有效防止深度嵌套的查询导致 DoS 攻击。
缺点:
- 过于严格的深度限制可能会限制合法用户的查询需求。
- 无法解决宽度优先的复杂查询。
查询复杂度分析 (Query Complexity Analysis)
深度限制只考虑了查询的嵌套层数,而忽略了查询的宽度。查询复杂度分析则综合考虑了查询的深度和宽度,以及每个字段的成本,从而更精确地评估查询的资源消耗。
实现原理:
- 为每个 GraphQL 字段定义一个成本值。成本值可以根据字段的计算复杂度、数据量等因素来确定。
- 在解析 GraphQL 查询之前,遍历查询树,计算查询的总成本。
- 如果查询的总成本超过了预设的限制,则拒绝执行该查询。
代码示例:
<?php
use GraphQLLanguageParser;
use GraphQLLanguageASTDocumentNode;
use GraphQLLanguageASTSelectionSetNode;
/**
* 定义字段成本
*
* @return array 字段成本数组
*/
function getFieldCosts(): array
{
return [
'user' => 1,
'id' => 0.1,
'name' => 0.2,
'posts' => 2,
'title' => 0.3,
'comments' => 3,
'text' => 0.4,
'author' => 1.5,
];
}
/**
* 计算 GraphQL 查询复杂度的函数
*
* @param DocumentNode $queryAST GraphQL 查询的抽象语法树
* @param array $fieldCosts 字段成本数组
* @return float 查询复杂度
*/
function calculateQueryComplexity(DocumentNode $queryAST, array $fieldCosts): float
{
$complexity = 0.0;
foreach ($queryAST->definitions as $definition) {
if (property_exists($definition, 'selectionSet') && $definition->selectionSet instanceof SelectionSetNode) {
$selectionSet = $definition->selectionSet;
foreach ($selectionSet->selections as $selection) {
$fieldName = $selection->name->value;
// 检查字段成本是否存在
if (!array_key_exists($fieldName, $fieldCosts)) {
// 可以记录日志并拒绝查询,因为存在未定义的字段
error_log("Undefined field cost for field: " . $fieldName);
return INF; // 使用 INF 表示复杂度无穷大,直接拒绝查询
}
$complexity += $fieldCosts[$fieldName];
if (property_exists($selection, 'selectionSet') && $selection->selectionSet instanceof SelectionSetNode) {
$subQueryAST = new DocumentNode(['definitions' => [new GraphQLLanguageASTOperationDefinitionNode(['selectionSet' => $selection->selectionSet])]]);
$complexity += calculateQueryComplexity($subQueryAST, $fieldCosts);
}
}
}
}
return $complexity;
}
/**
* 检查查询复杂度是否超过限制
*
* @param string $query GraphQL 查询字符串
* @param float $maxComplexity 允许的最大复杂度
* @return bool 是否超过限制
*/
function isQueryComplexityExceeded(string $query, float $maxComplexity): bool
{
try {
$queryAST = Parser::parse($query);
$fieldCosts = getFieldCosts();
$complexity = calculateQueryComplexity($queryAST, $fieldCosts);
return $complexity > $maxComplexity;
} catch (Exception $e) {
// 解析错误,可以记录日志并拒绝查询
error_log("GraphQL query parsing error: " . $e->getMessage());
return true; // 视为超过限制,拒绝查询
}
}
// 示例用法
$query = '{
user {
id
name
posts {
id
title
comments {
id
text
author {
id
name
}
}
}
}
}';
$maxComplexity = 20; // 允许的最大复杂度
if (isQueryComplexityExceeded($query, $maxComplexity)) {
http_response_code(400);
echo json_encode(['errors' => ['message' => 'Query complexity exceeds the maximum allowed complexity.']]);
} else {
// 执行查询
echo json_encode(['data' => ['message' => 'Query passed complexity validation.']]); // 模拟执行成功
}
?>
代码解释:
getFieldCosts()函数定义了每个字段的成本。成本值可以根据字段的计算复杂度、数据量等因素来确定。calculateQueryComplexity()函数递归地遍历 GraphQL 查询的抽象语法树 (AST),计算查询的复杂度。isQueryComplexityExceeded()函数使用GraphQLLanguageParser解析查询字符串,然后调用calculateQueryComplexity()计算复杂度,最后判断是否超过了预设的最大复杂度。- 在实际应用中,
$maxComplexity和字段成本应根据你的 API 的具体情况进行调整。 - 如果查询复杂度超过限制,返回 400 错误并给出相应的错误信息。
优点:
- 能够更精确地评估查询的资源消耗。
- 可以灵活地调整字段成本,以适应不同的业务场景。
缺点:
- 实现起来比深度限制更复杂。
- 需要仔细评估每个字段的成本,以避免误判。
- 如果成本设置不合理,仍然可能被绕过。
字段成本设置建议:
| 字段类型 | 成本建议 |
|---|---|
| 简单标量字段 (ID, String, Int, Boolean) | 低成本 (0.1 – 0.5)。这些字段通常计算量很小,不会对性能产生显著影响。 |
| 关联字段 (一对一, 一对多) | 中等成本 (1 – 5)。成本取决于关联数据的获取方式。如果需要进行数据库查询,成本会相对较高。一对多关系的成本应该高于一对一关系,因为可能会返回多个数据。 |
| 计算密集型字段 | 高成本 (5 – 20)。这些字段需要进行复杂的计算或数据处理。例如,需要进行图像处理、自然语言处理或复杂的聚合操作。 |
| 分页字段 | 成本与返回的数据量成正比。可以根据分页大小和数据的平均大小来估算成本。也可以考虑使用更精细的成本模型,例如,根据请求的分页大小动态调整成本。 |
| 突变 (Mutation) | 成本通常高于查询。因为突变会修改数据,可能需要进行事务处理、数据验证和权限检查。 |
| 列表字段 | 考虑使用乘数来增加成本。例如,如果一个字段返回一个列表,可以将其成本乘以列表的平均大小或最大大小。这可以防止攻击者通过请求大量数据来耗尽服务器资源。此外,对于可分页的列表,可以根据请求的分页大小动态调整成本。 |
输入校验 (Input Validation)
GraphQL 类型系统提供了一些基本的输入验证能力,例如类型检查。但是,为了确保数据的安全性,我们还需要进行更严格的输入校验,例如长度限制、格式验证、范围检查等。
实现原理:
- 在 GraphQL schema 中定义输入类型 (Input Types),并指定每个字段的类型、是否必填等信息。
- 在 resolver 函数中,对输入数据进行验证。如果输入数据不符合要求,则抛出错误。
代码示例:
<?php
use GraphQLTypeDefinitionObjectType;
use GraphQLTypeDefinitionType;
use GraphQLTypeSchema;
use GraphQLGraphQL;
use GraphQLTypeDefinitionInputObjectType;
// 定义输入类型
$userInputType = new InputObjectType([
'name' => 'UserInput',
'fields' => [
'name' => [
'type' => Type::nonNull(Type::string()),
'description' => 'User name',
],
'email' => [
'type' => Type::string(),
'description' => 'User email',
],
'age' => [
'type' => Type::int(),
'description' => 'User age',
],
],
]);
// 定义查询类型
$queryType = new ObjectType([
'name' => 'Query',
'fields' => [
'hello' => [
'type' => Type::string(),
'resolve' => function ($root, $args) {
return 'Hello World!';
},
],
],
]);
// 定义突变类型
$mutationType = new ObjectType([
'name' => 'Mutation',
'fields' => [
'createUser' => [
'type' => Type::string(),
'args' => [
'input' => [
'type' => Type::nonNull($userInputType),
'description' => 'User input',
],
],
'resolve' => function ($root, $args) {
$input = $args['input'];
// 输入校验
if (strlen($input['name']) < 3) {
throw new Exception('Name must be at least 3 characters long.');
}
if (!filter_var($input['email'], FILTER_VALIDATE_EMAIL)) {
throw new Exception('Invalid email format.');
}
if ($input['age'] < 0 || $input['age'] > 150) {
throw new Exception('Age must be between 0 and 150.');
}
// 模拟创建用户
return 'User created successfully with name: ' . $input['name'];
},
],
],
]);
// 创建 GraphQL schema
$schema = new Schema([
'query' => $queryType,
'mutation' => $mutationType,
]);
// GraphQL 查询字符串
$query = '
mutation CreateUser($input: UserInput!) {
createUser(input: $input)
}
';
// 查询变量
$variables = [
'input' => [
'name' => 'John Doe',
'email' => 'invalid-email',
'age' => 30,
],
];
try {
// 执行 GraphQL 查询
$result = GraphQL::executeQuery($schema, $query, null, null, $variables);
// 输出结果
echo json_encode($result);
} catch (Exception $e) {
// 处理异常
echo json_encode(['errors' => [['message' => $e->getMessage()]]]);
}
?>
代码解释:
$userInputType定义了一个输入类型UserInput,包含了name、email和age三个字段,并指定了它们的类型和描述。createUser突变接收一个UserInput类型的参数input。- 在
createUser的 resolver 函数中,对input中的每个字段进行验证。如果验证失败,则抛出异常。 - GraphQL 框架会自动将异常转换为 GraphQL 错误,并返回给客户端。
常用的输入校验方法:
| 校验类型 | 描述 | PHP 函数 / 库 |
|---|---|---|
| 类型检查 | 确保输入值的类型与 schema 中定义的类型一致。GraphQL 框架会自动进行类型检查。 | GraphQL 类型系统 |
| 长度限制 | 限制字符串或数组的长度。 | strlen(), count() |
| 格式验证 | 验证字符串的格式是否符合要求,例如 email 地址、电话号码、URL 等。 | filter_var(), 正则表达式 (preg_match()) |
| 范围检查 | 验证数字或日期的值是否在指定的范围内。 | 比较运算符 (>, <, >=, <=) |
| 枚举值检查 | 验证输入值是否在枚举类型定义的允许值列表中。 | in_array() |
| 正则表达式匹配 | 使用正则表达式验证输入值是否符合特定的模式。 | preg_match() |
| 自定义验证 | 编写自定义的验证函数,根据业务逻辑对输入值进行验证。 | 任何 PHP 函数 |
| 第三方验证库 | 使用第三方验证库,例如 RespectValidation, Valitron 等,提供更丰富的验证规则和功能。 | RespectValidation, Valitron |
优点:
- 能够有效防止恶意用户提交非法数据。
- 提高 API 的健壮性和安全性。
缺点:
- 需要编写额外的验证代码。
- 可能会增加 API 的开发和维护成本。
其他安全建议
除了深度限制、查询复杂度分析和输入校验之外,还有一些其他的安全建议可以帮助你保护你的 GraphQL API:
- 身份验证和授权: 确保只有经过身份验证的用户才能访问受保护的资源。使用适当的授权机制来控制用户对资源的访问权限。
- 速率限制: 限制每个用户的请求频率,防止 DoS 攻击。
- 日志记录和监控: 记录所有 API 请求和错误,以便进行安全审计和监控。
- 安全漏洞扫描: 定期使用安全漏洞扫描工具扫描你的 GraphQL API,及时发现和修复安全漏洞。
- 最小权限原则: 确保你的 API 代码只具有执行其所需功能的最小权限。
- 禁用 Introspection 在生产环境: 在生产环境中禁用 introspection 查询,防止攻击者获取你的 GraphQL schema 信息。可以通过配置 GraphQL 服务器来实现。
<?php
use GraphQLServerStandardServer;
use GraphQLTypeSchema;
// ... (定义你的 schema)
$server = new StandardServer([
'schema' => $schema,
'debug' => false, // 在生产环境中设置为 false
'introspectionAllowed' => false, // 在生产环境中设置为 false
]);
$server->handleRequest();
?>
总结一下
GraphQL 的安全问题需要我们认真对待。通过深度限制、查询复杂度分析和输入校验等手段,我们可以有效地保护我们的 GraphQL API,防止 DoS 攻击、信息泄露和身份验证绕过等安全风险。同时,我们也应该关注其他的安全建议,例如身份验证和授权、速率限制、日志记录和监控等,以构建更安全的 GraphQL 服务。