PHP GraphQL安全指南:深度限制、查询复杂度分析与输入校验实践

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.']]); // 模拟执行成功
}

?>

代码解释:

  1. calculateQueryDepth() 函数递归地遍历 GraphQL 查询的抽象语法树 (AST),计算查询的深度。
  2. isQueryDepthExceeded() 函数使用 GraphQLLanguageParser 解析查询字符串,然后调用 calculateQueryDepth() 计算深度,最后判断是否超过了预设的最大深度。
  3. 在实际应用中,$maxDepth 的值应根据你的 API 的具体情况进行调整。
  4. 如果查询深度超过限制,返回 400 错误并给出相应的错误信息。

优点:

  • 简单有效,易于实现。
  • 能够有效防止深度嵌套的查询导致 DoS 攻击。

缺点:

  • 过于严格的深度限制可能会限制合法用户的查询需求。
  • 无法解决宽度优先的复杂查询。

查询复杂度分析 (Query Complexity Analysis)

深度限制只考虑了查询的嵌套层数,而忽略了查询的宽度。查询复杂度分析则综合考虑了查询的深度和宽度,以及每个字段的成本,从而更精确地评估查询的资源消耗。

实现原理:

  1. 为每个 GraphQL 字段定义一个成本值。成本值可以根据字段的计算复杂度、数据量等因素来确定。
  2. 在解析 GraphQL 查询之前,遍历查询树,计算查询的总成本。
  3. 如果查询的总成本超过了预设的限制,则拒绝执行该查询。

代码示例:

<?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.']]); // 模拟执行成功
}

?>

代码解释:

  1. getFieldCosts() 函数定义了每个字段的成本。成本值可以根据字段的计算复杂度、数据量等因素来确定。
  2. calculateQueryComplexity() 函数递归地遍历 GraphQL 查询的抽象语法树 (AST),计算查询的复杂度。
  3. isQueryComplexityExceeded() 函数使用 GraphQLLanguageParser 解析查询字符串,然后调用 calculateQueryComplexity() 计算复杂度,最后判断是否超过了预设的最大复杂度。
  4. 在实际应用中,$maxComplexity 和字段成本应根据你的 API 的具体情况进行调整。
  5. 如果查询复杂度超过限制,返回 400 错误并给出相应的错误信息。

优点:

  • 能够更精确地评估查询的资源消耗。
  • 可以灵活地调整字段成本,以适应不同的业务场景。

缺点:

  • 实现起来比深度限制更复杂。
  • 需要仔细评估每个字段的成本,以避免误判。
  • 如果成本设置不合理,仍然可能被绕过。

字段成本设置建议:

字段类型 成本建议
简单标量字段 (ID, String, Int, Boolean) 低成本 (0.1 – 0.5)。这些字段通常计算量很小,不会对性能产生显著影响。
关联字段 (一对一, 一对多) 中等成本 (1 – 5)。成本取决于关联数据的获取方式。如果需要进行数据库查询,成本会相对较高。一对多关系的成本应该高于一对一关系,因为可能会返回多个数据。
计算密集型字段 高成本 (5 – 20)。这些字段需要进行复杂的计算或数据处理。例如,需要进行图像处理、自然语言处理或复杂的聚合操作。
分页字段 成本与返回的数据量成正比。可以根据分页大小和数据的平均大小来估算成本。也可以考虑使用更精细的成本模型,例如,根据请求的分页大小动态调整成本。
突变 (Mutation) 成本通常高于查询。因为突变会修改数据,可能需要进行事务处理、数据验证和权限检查。
列表字段 考虑使用乘数来增加成本。例如,如果一个字段返回一个列表,可以将其成本乘以列表的平均大小或最大大小。这可以防止攻击者通过请求大量数据来耗尽服务器资源。此外,对于可分页的列表,可以根据请求的分页大小动态调整成本。

输入校验 (Input Validation)

GraphQL 类型系统提供了一些基本的输入验证能力,例如类型检查。但是,为了确保数据的安全性,我们还需要进行更严格的输入校验,例如长度限制、格式验证、范围检查等。

实现原理:

  1. 在 GraphQL schema 中定义输入类型 (Input Types),并指定每个字段的类型、是否必填等信息。
  2. 在 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()]]]);
}

?>

代码解释:

  1. $userInputType 定义了一个输入类型 UserInput,包含了 nameemailage 三个字段,并指定了它们的类型和描述。
  2. createUser 突变接收一个 UserInput 类型的参数 input
  3. createUser 的 resolver 函数中,对 input 中的每个字段进行验证。如果验证失败,则抛出异常。
  4. 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 服务。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注