🛡️ PHP 接口防护实战:GraphQL 查询深度限制与字段选取防护

发布于
1

一条没带防护的 GraphQL 接口,恶意嵌套查询 30 秒就能把数据库拖垮。GraphQL 把查询自由度交给了客户端,爽了前端,也把风险敞口留给了后端。这篇把 PHP 生态下最实用的防护方案一次讲透,代码可直接落地。

没防护的接口等于裸奔

GraphQL 一个入口查所有数据,风险集中在三点:

  • 🔗 嵌套无上限:user → friends → friends → friends……每多一层,后端数据加载量指数级膨胀

  • 🌊 字段全选零成本:一条查询把几十张表的字段全拉一遍,数据库瞬间高负载

  • 🗺️ 内省暴露全部结构:一条 __schema 查询,所有类型、字段、参数一览无余,攻击面完全透明

这类攻击门槛极低,Postman 手写一条嵌套查询即可复现。

第一道闸门:查询深度限制

PHP 生态主流的 GraphQL 服务端实现是 webonyx/graphql-php,库内置了安全规则 QueryDepth,但默认不启用,必须手动挂上:

use GraphQL\Validator\DocumentValidator;
use GraphQL\Validator\Rules\QueryDepth;

DocumentValidator::addRule(new QueryDepth(8));

一行代码,所有查询在执行前先过校验,嵌套超过 8 层直接拒绝并返回错误,数据库完全不受波及。

深度设多少?常规业务 5~8 层足够。真实业务里极少出现三层以上的关联查询,阈值放太宽等于没设。

第二道闸门:查询复杂度计分

只限深度有漏洞:浅层但宽得离谱的查询照样打爆数据库,比如一次拉 1 万个用户的完整信息。这时候上 QueryComplexity

use GraphQL\Validator\Rules\QueryComplexity;

DocumentValidator::addRule(new QueryComplexity(200));

规则给每个字段计 1 分,总分超限即拒。关键在于列表字段的计分逻辑——按分页参数动态放大:

'users' => [
    'type' => Type::listOf($userType),
    'args' => [
        'first' => ['type' => Type::int(), 'defaultValue' => 20],
    ],
    'complexity' => fn(int $childrenComplexity, array $args): int =>
        max(1, $args['first']) * $childrenComplexity,
    'resolve' => fn($root, array $args) =>
        UserRepository::paginate(min($args['first'], 100)),
],

first=100 时,该字段成本 = 100 × 子字段分数。恶意批量拉取一提交,复杂度立刻爆表被拦。

📌 深度限制管”钻多深”,复杂度限制管”铺多宽”,两道闸门缺一不可。

第三道闸门:字段选取防护

查询规模受控之后,字段本身还分三六九等。手机号、身份证号、薪资、内部备注这类字段,必须做字段级权限隔离:

'salary' => [
    'type' => Type::float(),
    'resolve' => function (User $user, array $args, AppContext $context) {
        if (!$context->isAdmin()) {
            return null;
        }
        return $user->salary;
    },
],

三条铁律:

  1. 敏感字段从一开始就别写进 Schema,物理隔离最彻底

  2. 必须暴露的字段,权限判断写进 resolve,无权返回 null,绝不在报错里泄露字段存在性

  3. 权限逻辑统一收进 Context 对象,不要散落到每个 resolve 各写一套

生产环境必须关掉内省

内省(Introspection)是开发期的好帮手,上线后就是攻击者的地图。生产环境直接禁用:

use GraphQL\Validator\Rules\DisableIntrospection;

if (getenv('APP_ENV') === 'production') {
    DocumentValidator::addRule(new DisableIntrospection());
}

关掉之后 __schema__type 查询一律报错,Schema 结构不再对外透明。开发环境保持开启,GraphiQL 调试不受影响。

别漏了分页上限与 N+1

两道附加题,漏做照样出事:

  • 📄 分页参数封顶:firstlast 强制上限 100,resolve 里用 min() 兜底,客户端传值永远不可信

  • 🔁 N+1 查询:列表场景上 DataLoader 批量加载,否则 100 条数据就是 101 次 SQL,可搭配 overblog/dataloader 或自行实现缓冲合并

完整配置,直接抄作业

<?php
declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

use GraphQL\GraphQL;
use GraphQL\Validator\DocumentValidator;
use GraphQL\Validator\Rules\QueryDepth;
use GraphQL\Validator\Rules\QueryComplexity;
use GraphQL\Validator\Rules\DisableIntrospection;

// 安全规则统一注册,所有查询执行前强制校验
DocumentValidator::addRule(new QueryDepth(8));
DocumentValidator::addRule(new QueryComplexity(200));

if (getenv('APP_ENV') === 'production') {
    DocumentValidator::addRule(new DisableIntrospection());
}

$input  = json_decode(file_get_contents('php://input'), true) ?: [];
$result = GraphQL::executeQuery(
    $schema,
    $input['query'] ?? '',
    null,
    null,
    $input['variables'] ?? null
)->toArray();

header('Content-Type: application/json; charset=utf-8');
echo json_encode($result, JSON_UNESCAPED_UNICODE);

Laravel 项目用 Lighthouse 更省事:config/lighthouse.phpsecurity 配置项直接设置 max_depthmax_complexitydisable_introspection,无需手动注册。

总结:深度限制管嵌套、复杂度计分管规模、字段权限管内容、关内省管地图。四件套配齐,GraphQL 接口才算真正能上线 ✅

常见问题(FAQ)

QueryDepth 和 QueryComplexity 只装一个行不行?
不建议。深度限制拦不住"浅而宽"的批量拉取,复杂度限制对深层嵌套的拦截又依赖计分准确性,两者互补,必须同时启用。
关闭内省后,前端怎么获取字段文档?
开发环境保留内省供调试;生产环境的 Schema 文档通过 CI 导出为静态文件,放在内部文档站,不随接口暴露。
安全规则会不会拖慢接口性能?
校验发生在查询解析阶段,只分析 AST、不触碰数据库,开销在毫秒级以内。相比被恶意查询拖垮数据库的代价,可以忽略。
Laravel 项目怎么接入这些规则?
使用 Lighthouse 时在配置文件中开启即可,原生项目照抄第 7 节的注册代码,效果一致。
有没有更严格的进阶方案?
有。查询白名单(Persisted Query)只允许预注册过的查询哈希执行,未登记的查询一律拒绝,适合支付、账户类高安全接口。
0 讨论
热门最新
总结
暂无总结
0 / 600
嗨,下午好!
所有的成功,都源自一个勇敢的开始
¥10.00
10元抵扣券
已过期