篇首语:本文由编程笔记#小编为大家整理,主要介绍了GraphQL安全指北相关的知识,希望对你有一定的参考价值。
* 在616先知白帽大会上听到@phith0n大佬的议题《攻击GraphQL》,从攻击者视角描述了GraphQL的攻击面。让我想起之前在做某个项目时,鬼使神差的(其实是健忘症又犯了)学习并尝试了GraphQL这个还没完全火起来但又有很多大厂使用的Web API技术,当时和好基友@图南也对其安全性相关问题存在的疑虑做了很多探讨和研究,于是决定和他联名合作完成这篇关于GraphQL安全的文章。我俩水平有限,不足之处请批评指正。
本文以GraphQL中一些容易让初学者与典型Web API(为了便于理解,下文以目前流行的RESTful API为例代指)混淆或错误理解的概念特性进行内容划分,由我从安全的角度抛出GraphQL应该注意的几点安全问题,而@图南则会更多的从开发的角度给出他在实际使用过程中总结的最佳实践。
另外,需要提前声明的是,本文中我使用的后端开发语言是Go,@图南使用的是Node.js,前端统一为React(GraphQL客户端为Apollo),请大家自行消化。
Let’s Go!
有些同学是不是根本没听过这个玩意?我们先来看看正在使用它的大客户们:
是不是值得我们花几分钟对它做个简单的了解了?XD
简单的说,GraphQL是由Facebook创造并开源的一种用于API的查询语言。
再引用官方文案来帮助大家理解一下GraphQL的特点:
请求你所要的数据,不多不少
向你的API发出一个GraphQL请求就能准确获得你想要的数据,不多不少。GraphQL查询总是返回可预测的结果。使用GraphQL的应用可以工作得又快又稳,因为控制数据的是应用,而不是服务器
获取多个资源,只用一个请求
GraphQL查询不仅能够获得资源的属性,还能沿着资源间引用进一步查询。典型的RESTful API请求多个资源时得载入多个URL,而GraphQL可以通过一次请求就获取你应用所需的所有数据
描述所有的可能,类型系统
GraphQL基于类型和字段的方式进行组织,而非入口端点。你可以通过一个单一入口端点得到你所有的数据能力。GraphQL使用类型来保证应用只请求可能的数据,还提供了清晰的辅助性错误信息
Type
用于描述接口的抽象数据模型,有Scalar(标量)和Object(对象)两种,Object由Field组成,同时Field也有自己的Type
Schema
用于描述接口获取数据的逻辑,类比RESTful中的每个独立资源URI
Query
用于描述接口的查询类型,有Query(查询)、Mutation(更改)和Subscription(订阅)三种
Resolver
用于描述接口中每个Query的解析逻辑,部分GraphQL引擎还提供Field细粒度的Resolver
(想要详细了解的同学请阅读GraphQL官方文档)
GraphQL没有过多依赖HTTP协议,它有一套自己的解析引擎来帮助前后端使用GraphQL查询语法。同时它是单路由形态,查询内容完全根据前端请求对象和字段而定,前后端分离较明显。
用一张图来对比一下:
@gyyyy:
前面说到,GraphQL多了一个中间层对它定义的查询语言进行语法解析执行等操作,与RESTful这种充分利用HTTP协议本身特性完成声明使用的API设计不同,Schema、Resolver等种种定义会让开发者对它的存在感知较大,间接的增加了对它理解的复杂度,加上它本身的单路由形态,很容易导致开发者在不完全了解其特性和内部运行机制的情况下,错误实现甚至忽略API调用时的授权鉴权行为。
在官方的描述中,GraphQL和RESTful API一样,建议开发者将授权逻辑委托给业务逻辑层:
在没有对GraphQL中各个Query和Mutation做好授权鉴权时,同样可能会被攻击者非法请求到一些非预期接口,执行高危操作,如查询所有用户的详细信息:
query GetAllUsers {
users {
_id
username
password
idCard
mobilePhone
email
}
}
这几乎是使用任何API技术都无法避免的一个安全问题,因为它与API本身的职能并没有太大的关系,API不需要背这个锅,但由此问题带来的并发症却不容小觑。
对于这种未授权或越权访问漏洞的挖掘利用方式,大家一定都很清楚了,一般情况下我们都会期望尽可能获取到比较全量的API来进行进一步的分析。在RESTful API中,我们可能需要通过代理、爬虫等技术来抓取API。而随着Web 2.0时代的到来,各种强大的前端框架、运行时DOM事件更新等技术使用频率的增加,更使得我们不得不动用到如Headless等技术来提高对API的获取覆盖率。
但与RESTful API不同的是,GraphQL自带强大的内省自检机制,可以直接获取后端定义的所有接口信息。比如通过__schema
查询所有可用对象:
{
__schema {
types {
name
}
}
}
通过__type
查询指定对象的所有字段:
{
__type(name: "User") {
name
fields {
name
type {
name
}
}
}
}
这里我通过graphql-go/graphql的源码简单分析一下GraphQL的解析执行流程和内省机制,帮助大家加深理解:
GraphQL路由节点在拿到HTTP的请求参数后,创建Params
对象,并调用Do()
完成解析执行操作返回结果:
params := graphql.Params{
Schema: *h.Schema,
RequestString: opts.Query,
VariableValues: opts.Variables,
OperationName: opts.OperationName,
Context: ctx,
}
result := graphql.Do(params)
调用Parser()
把params.RequestString
转换为GraphQL的AST文档后,将AST和Schema一起交给ValidateDocument()
进行校验(主要校验是否符合Schema定义的参数、字段、类型等)
代入AST重新封装ExecuteParams
对象,传入Execute()
中开始执行当前GraphQL语句
具体的执行细节就不展开了,但是我们关心的内省去哪了?原来在GraphQL引擎初始化时,会定义三个带缺省Resolver的元字段:
SchemaMetaFieldDef = &FieldDefinition{ // __schema:查询当前类型定义的模式,无参数
Name: "__schema",
Type: NewNonNull(SchemaType),
Description: "Access the current type schema of this server.",
Args: []*Argument{},
Resolve: func(p ResolveParams) (interface{}, error) {
return p.Info.Schema, nil
},
}
TypeMetaFieldDef = &FieldDefinition{ // __type:查询指定类型的详细信息,字符串类型参数`name`
Name: "__type",
Type: TypeType,
Description: "Request the type information of a single type.",
Args: []*Argument{
{
PrivateName: "name",
Type: NewNonNull(String),
},
},
Resolve: func(p ResolveParams) (interface{}, error) {
name, ok := p.Args["name"].(string)
if !ok {
return nil, nil
}
return p.Info.Schema.Type(name), nil
},
}
TypeNameMetaFieldDef = &FieldDefinition{ // __typename:查询当前对象类型名称,无参数
Name: "__typename",
Type: NewNonNull(String),
Description: "The name of the current Object type at runtime.",
Args: []*Argument{},
Resolve: func(p ResolveParams) (interface{}, error) {
return p.Info.ParentType.Name(), nil
},
}
当resolveField()
解析到元字段时,会调用其缺省Resolver,触发GraphQL的内省逻辑。
GraphQL为了考虑接口在版本演进时能够向下兼容,还有一个对于应用开发而言比较友善的特性:『API演进无需划分版本』。
由于GraphQL是根据前端请求的字段进行数据回传,后端Resolver的响应包含对应字段即可,因此后端字段扩展对前端无感知无影响,前端增加查询字段也只要在后端定义的字段范围内即可。同时GraphQL也为字段删除提供了『废弃』方案,如Go的graphql
包在字段中增加DeprecationReason
属性,Apollo的@deprecated
标识等。
这种特性非常方便的将前后端进行了分离,但如果开发者本身安全意识不够强,设计的API不够合理,就会埋下了很多安全隐患。我们用开发项目中可能会经常遇到的需求场景来重现一下。
假设小明在应用中已经定义好了查询用户基本信息的API:
graphql.Field{
Type: graphql.NewObject(graphql.ObjectConfig{
Name: "User",
Description: "用户信息",
Fields: graphql.Fields{
"_id": &graphql.Field{Type: graphql.Int},
"username": &graphql.Field{Type: graphql.String},
"email": &graphql.Field{Type: graphql.String},
},
}),
Args: graphql.FieldConfigArgument{
"username": &graphql.ArgumentConfig{Type: graphql.String},
},
Resolve: func(params graphql.ResolveParams) (result interface{}, err error) {
// ...
},
}
小明获得新的需求描述,『管理员可以查询指定用户的详细信息』,为了方便(也经常会为了方便),于是在原有接口上新增了几个字段:
graphql.Field{
Type: graphql.NewObject(graphql.ObjectConfig{
Name: "User",
Description: "用户信息",
Fields: graphql.Fields{
"_id": &graphql.Field{Type: graphql.Int},
"username": &graphql.Field{Type: graphql.String},
"password": &graphql.Field{Type: graphql.String}, // 新增 用户密码 字段
"idCard": &graphql.Field{Type: graphql.String}, // 新增 用户身份证号 字段
"mobilePhone": &graphql.Field{Type: graphql.String}, // 新增 用户手机号 字段
"email": &graphql.Field{Type: graphql.String},
},
}),
Args: graphql.FieldConfigArgument{
"username": &graphql.ArgumentConfig{Type: graphql.String},
},
Resolve: func(params graphql.ResolveParams) (result interface{}, err error) {
// ...
},
}
如果此时小明没有在字段细粒度上进行权限控制(也暂时忽略其他权限问题),攻击者可以轻易的通过内省发现这几个本不该被普通用户查看到的字段,并构造请求进行查询(实际开发中也经常容易遗留一些测试字段,在GraphQL强大的内省机制面前这无疑是非常危险的。如果熟悉Spring自动绑定漏洞的同学,也会发现它们之间有一部分相似的地方)。
故事继续,当小明发现这种做法欠妥时,他决定废弃这几个字段:
// ...
"password": &graphql.Field{Type: graphql.String, DeprecationReason: "安全性问题"},
"idCard": &graphql.Field{Type: graphql.String, DeprecationReason: "安全性问题"},
"mobilePhone": &graphql.Field{Type: graphql.String, DeprecationReason: "安全性问题"},
// ...
接着,他又用上面的__type
做了一次内省,很好,废弃字段查不到了,通知前端回滚查询语句,问题解决,下班回家(GraphQL的优势立刻凸显出来)。
熟悉安全攻防套路的同学都知道,很多的攻击方式(尤其在Web安全中)都是利用了开发、测试、运维的知识盲点(如果你想问这些盲点的产生原因,我只能说是因为正常情况下根本用不到,所以不深入研究基本不会去刻意关注)。如果开发者没有很仔细的阅读GraphQL官方文档,特别是内省这一章节的内容,就可能不知道,通过指定includeDeprecated
参数为true
,__type
仍然可以将废弃字段暴露出来:
{
__type(name: "User") {
name
fields(includeDeprecated: true) {
name
isDeprecated
type {
name
}
}
}
}
而且由于小明没有对Resolver做修改,废弃字段仍然可以正常参与查询(兼容性惹的祸),故事结束。
正如p牛所言,『GraphQL是一门自带文档的技术』。可这也使得授权鉴权环节一旦出现纰漏,GraphQL背后的应用所面临的安全风险会比典型Web API大得多。
@图南:
GraphQL并没有规定任何身份认证和权限控制的相关内容,这是个好事情,因为我们可以更灵活的在应用中实现各种粒度的认证和权限。但是,在我的开发过程中发现,初学者经常会忽略GraphQL的认证,会写出一些裸奔的接口或者无效认证的接口。那么我就在这里详细说一下GraphQL的认证方式。
如果后端本身支持RESTful或者有专门的认证服务器,可以修改少量代码就能实现GraphQL接口的认证。这种认证方式是最通用同时也是官方比较推荐的。
以JWT认证为例,将整个GraphQL路由加入JWT认证,开放两个RESTful接口做登录和注册用,登录和注册的具体逻辑不再赘述,登录后返回JWT Token:
// ...
router.post('/login', LoginController.login);
router.post('/register', LoginController.register);
app.use(koajwt({secret: 'your secret'}).unless({
path: [/^\/public/, '/login', '/register']
}));
const server = new ApolloServer({
typeDefs: schemaText,
resolvers: resolverMap,
context: ({ctx}) => ({
...ctx,
...app.context
})
});
server.applyMiddleware({app});
app.listen({
port: 4000
}, () => console.log(`