一条没带防护的 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;
},
],三条铁律:
-
敏感字段从一开始就别写进 Schema,物理隔离最彻底
-
必须暴露的字段,权限判断写进 resolve,无权返回
null,绝不在报错里泄露字段存在性 -
权限逻辑统一收进 Context 对象,不要散落到每个 resolve 各写一套
生产环境必须关掉内省
内省(Introspection)是开发期的好帮手,上线后就是攻击者的地图。生产环境直接禁用:
use GraphQL\Validator\Rules\DisableIntrospection;
if (getenv('APP_ENV') === 'production') {
DocumentValidator::addRule(new DisableIntrospection());
}关掉之后 __schema、__type 查询一律报错,Schema 结构不再对外透明。开发环境保持开启,GraphiQL 调试不受影响。
别漏了分页上限与 N+1
两道附加题,漏做照样出事:
-
📄 分页参数封顶:
first、last强制上限 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.php 的 security 配置项直接设置 max_depth、max_complexity、disable_introspection,无需手动注册。
总结:深度限制管嵌套、复杂度计分管规模、字段权限管内容、关内省管地图。四件套配齐,GraphQL 接口才算真正能上线 ✅

