這篇文章給大家介紹如何進行GraphQL的分析,內容非常詳細,感興趣的小伙伴們可以參考借鑒,希望對大家能有所幫助。
下面以GraphQL中一些容易讓初學者與典型Web API(為了便于理解,下文以目前流行的RESTful API為例代指)混淆或錯誤理解的概念特性進行內容劃分,由我從安全的角度拋出GraphQL應該注意的幾點安全問題,而@圖南則會更多的從開發的角度給出他在實際使用過程中總結的最佳實踐。
另外,需要提前聲明的是,文中我使用的后端開發語言是Go,@圖南使用的是Node.js,前端統一為React(GraphQL客戶端為Apollo),請大家自行消化。
Let’s Go!
有些同學是不是根本沒聽過這個玩意?我們先來看看正在使用它的大客戶們:
是不是值得我們花幾分鐘對它做個簡單的了解了?XD
簡單的說,GraphQL是由Facebook創造并開源的一種用于API的查詢語言。
再引用官方文案來幫助大家理解一下GraphQL的特點:
1.請求你所要的數據,不多不少
向你的API發出一個GraphQL請求就能準確獲得你想要的數據,不多不少。GraphQL查詢總是返回可預測的結果。使用GraphQL的應用可以工作得又快又穩,因為控制數據的是應用,而不是服務器。
2.獲取多個資源,只用一個請求
GraphQL查詢不僅能夠獲得資源的屬性,還能沿著資源間引用進一步查詢。典型的RESTful API請求多個資源時得載入多個URL,而GraphQL可以通過一次請求就獲取你應用所需的所有數據。
3.描述所有的可能,類型系統
GraphQL基于類型和字段的方式進行組織,而非入口端點。你可以通過一個單一入口端點得到你所有的數據能力。GraphQL使用類型來保證應用只請求可能的數據,還提供了清晰的輔助性錯誤信息。
用于描述接口的抽象數據模型,有Scalar(標量)和Object(對象)兩種,Object由Field組成,同時Field也有自己的Type。
用于描述接口獲取數據的邏輯,類比RESTful中的每個獨立資源URI。
用于描述接口的查詢類型,有Query(查詢)、Mutation(更改)和Subscription(訂閱)三種。
用于描述接口中每個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的解析執行流程和內省機制,幫助大家加深理解:
1.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)
2.調用Parser()把params.RequestString轉換為GraphQL的AST文檔后,將AST和Schema一起交給ValidateDocument()進行校驗(主要校驗是否符合Schema定義的參數、字段、類型等)。
3.代入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:
設置完成后,請求GraphQL接口需要先進行登錄操作,然后在前端配置好認證請求頭來訪問GraphQL接口,以curl代替前端請求登錄RESTful接口:
curl -X POST http://localhost:4000/login -H 'cache-control: no-cache' -H 'content-type: application/x-www-form-urlencoded' -d 'username=user1&password=123456' {"message":"登錄成功","token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkYXRhIjp7Il9pZCI6IjViNWU1NDcwN2YyZGIzMDI0YWJmOTY1NiIsInVzZXJuYW1lIjoidXNlcjEiLCJwYXNzd29yZCI6IiQyYSQwNSRqekROOGFQbEloRzJlT1A1ZW9JcVFPRzg1MWdBbWY0NG5iaXJaM0Y4NUdLZ3pVL3lVNmNFYSJ9LCJleHAiOjE1MzI5MTIyOTEsImlhdCI6MTUzMjkwODY5MX0.Uhd_EkKUEDkI9cdnYlOC7wSYZdYLQLFCb01WhSBeTpY"}
以GraphiQL(GraphQL開發者調試工具,大部分GraphQL引擎自帶,默認開啟)代替前端請求GraphQL接口,要先設置認證請求頭:
如果GraphQL后端只能支持GraphQL不能支持RESTful,或者全部請求都需要使用GraphQL,也可以用GraphQL構造login接口提供Token。
如下面例子,構造login的Query Schema, 由返回值中攜帶Token:
type Query { login( username: String! password: String! ): LoginMsg } type LoginMsg { message: String token: String }
在Resolver中提供登錄邏輯:
import bcrypt from 'bcryptjs'; import jsonwebtoken from 'jsonwebtoken'; export const login = async (_, args, context) => { const db = await context.getDb(); const { username, password } = args; const user = await db.collection('User').findOne({ username: username }); if (await bcrypt.compare(password, user.password)) { return { message: 'Login success', token: jsonwebtoken.sign({ user: user, exp: Math.floor(Date.now() / 1000) + (60 * 60), // 60 seconds * 60 minutes = 1 hour }, 'your secret'), }; } }
登錄成功后,我們繼續把Token設置在請求頭中,請求GraphQL的其他接口。這時我們要對ApolloServer進行如下配置:
const server = new ApolloServer({ typeDefs: schemaText, resolvers: resolverMap, context: ({ ctx }) => { const token = ctx.req.headers.authorization || ''; const user = getUser(token); return { ...user, ...ctx, ...app.context }; }, });
實現getUser函數:
const getUser = (token) => { let user = null; const parts = token.split(' '); if (parts.length === 2) { const scheme = parts[0]; const credentials = parts[1]; if (/^Bearer$/i.test(scheme)) { token = credentials; try { user = jwt.verify(token, JWT_SECRET); console.log(user); } catch (e) { console.log(e); } } } return user }
配置好ApolloServer后,在Resolver中校驗user:
import { ApolloError, ForbiddenError, AuthenticationError } from 'apollo-server'; export const blogs = async (_, args, context) => { const db = await context.getDb(); const user = context.user; if(!user) { throw new AuthenticationError("You must be logged in to see blogs"); } const { blogId } = args; const cursor = {}; if (blogId) { cursor['_id'] = blogId; } const blogs = await db .collection('blogs') .find(cursor) .sort({ publishedAt: -1 }) .toArray(); return blogs; }
這樣我們即完成了通過GraphQL認證的主要代碼。繼續使用GraphiQL代替前端請求GraphQL登錄接口:
得到Token后,設置Token到請求頭 完成后續操作。如果請求頭失效,則得不到數據:
在認證過程中,我們只是識別請求是不是由合法用戶發起。權限控制可以讓我們為用戶分配不同的查看權限和操作權限。如上,我們已經將user放入GraphQL Sever的context中。而context的內容又是我們可控的,因此context中的user既可以是{ loggedIn: true },又可以是{ user: { _id: 12345, roles: ['user', 'admin'] } }。大家應該知道如何在Resolver中實現權限控制了吧,簡單的舉個例子:
users: (root, args, context) => { if (!context.user || !context.user.roles.includes('admin')) throw ForbiddenError("You must be an administrator to see all Users"); return User.getAll(); }
@gyyyy:
有語法就會有解析,有解析就會有結構和順序,有結構和順序就會有注入。
前端使用變量構建帶參查詢語句:
const id = props.match.params.id; const queryUser = gql`{ user(_id: ${id}) { _id username email } }`
name的值會在發出GraphQL查詢請求前就被拼接進完整的GraphQL語句中。攻擊者對name注入惡意語句:
-1)%7B_id%7Dhack%3Auser(username%3A"admin")%7Bpassword%23
可能GraphQL語句的結構就被改變了:
{ user(_id: -1) { _id } hack: user(username: "admin") { password #) { _id username email } }
因此,帶參查詢一定要保證在后端GraphQL引擎解析時,原語句結構不變,參數值以變量的形式被傳入,由解析器實時賦值解析。
@圖南:
幸運的是,GraphQL同時提供了『參數』和『變量』給我們使用。我們可以將參數值的拼接過程轉交給后端GraphQL引擎,前端就像進行參數化查詢一樣。
例如,我們定義一個帶變量的Query:
type Query { user( username: String! ): User }
請求時傳入變量:
query GetUser($name: String!) { user(username: $name) { _id username email } } // 變量 {"name": "some username"}
@gyyyy:
做過代碼調試的同學可能會注意過,在觀察的變量中存在相互關聯的對象時,可以對它們進行無限展開(比如一些Web框架的Request-Response對)。如果這個關聯關系不是引用而是值,就有可能出現OOM等問題導致運算性能下降甚至應用運行中斷。同理,在一些動態求值的邏輯中也會存在這類問題,比如XXE的拒絕服務。
GraphQL中也允許對象間包含組合的嵌套關系存在,如果不對嵌套深度進行限制,就會被攻擊者利用進行拒絕服務攻擊。
@圖南:
在開發中,我們可能經常會遇到這樣的需求:
1. 查詢所有文章,返回內容中包含作者信息
2. 查詢作者信息,返回內容中包含此作者寫的所有文章
當然,在我們開發的前端中這兩個接口一定是單獨使用的,但攻擊者可以利用這它們的包含關系進行嵌套查詢。
如下面例子,我們定義了Blog和Author:
type Blog { _id: String! type: BlogType avatar: String title: String content: [String] author: Author # ... } type Author { _id: String! name: String blog: [Blog] }
構建各自的Query:
extend type Query { blogs( blogId: ID systemType: String! ): [Blog] } extend type Query { author( _id: String! ): Author }
我們可以構造如下的查詢,此查詢可無限循環下去,就有可能造成拒絕服務攻擊:
query GetBlogs($blogId: ID, $systemType: String!) { blogs(blogId: $blogId, systemType: $systemType) { _id title type content author { name blog { author { name blog { author { name blog { author { name blog { author { name blog { author { name blog { author { name blog { author { name # and so on... } } } } } } } } } } } } } title createdAt publishedAt } } publishedAt } }
避免此問題我們需要在GraphQL服務器上限制查詢深度,同時在設計GraphQL接口時應盡量避免出現此類問題。仍然以Node.js為例,graphql-depth-limit就可以解決這樣的問題。
// ... import depthLimit from 'graphql-depth-limit'; // ... const server = new ApolloServer({ typeDefs: schemaText, resolvers: resolverMap, context: ({ ctx }) => { const token = ctx.req.headers.authorization || ''; const user = getUser(token); console.log('user',user) return { ...user, ...ctx, ...app.context }; }, validationRules: [ depthLimit(10) ] });// ...
添加限制后,請求深度過大時會看到如下報錯信息:
@gyyyy:
作為Web API的一員,GraphQL和RESTful API一樣,有可能被攻擊者通過對參數注入惡意數據影響到后端應用,產生XSS、SQL注入、RCE等安全問題。此外,上文也提到了很多GraphQL的特性,一些特殊場景下,這些特性會被攻擊者利用來優化攻擊流程甚至增強攻擊效果。比如之前說的內省機制和默認開啟的GraphiQL調試工具等,還有它同時支持GET和POST兩種請求方法,對于CSRF這些漏洞的利用會提供更多的便利。
當然,有些特性也提供了部分保護能力,不過只是『部分』而已。
@圖南:
GraphQL的類型系統對注入是一層天然屏障,但是如果開發者的處理方式不正確,仍然會有例外。
比如下面的例子,參數類型是字符串:
query GetAllUsers($filter: String!) { users(filter: $filter) { _id username email } }
假如后端沒有對filter的值進行任何安全性校驗,直接查詢數據庫,傳入一段SQL語句字符串,可能構成SQL注入:
{"filter": "' or ''='"}
或者JSON字符串構成NoSQL注入:
{"filter": "{\"$ne\": null}"}
GraphQL真的只是一個API技術,它為API連接的前后端提供了一種新的便捷處理方案。無論如何,該做鑒權的就鑒權,該校驗數據的還是一定得校驗。
關于如何進行GraphQL的分析就分享到這里了,希望以上內容可以對大家有一定的幫助,可以學到更多知識。如果覺得文章不錯,可以把它分享出去讓更多的人看到。
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。