GraphQL安全指北
前幾天的會上聽到@phith0n大佬的議題《攻擊GraphQL》,從攻擊者視角描述了GraphQL的攻擊面。讓我想起之前在做某個專案時,鬼使神差的(其實是健忘症又犯了)學習並嘗試了GraphQL這個還沒完全火起來但又有很多大廠使用的Web API技術,當時和好基友@圖南也對其安全性相關問題存在的疑慮做了很多探討和研究,於是決定和他聯名合作完成這篇關於GraphQL安全的文章。我倆水平有限,不足之處請批評指正。
說在前面的話
本文以GraphQL中一些容易讓初學者與典型Web API(為了便於理解,下文以目前流行的RESTful API為例代指)混淆或錯誤理解的概念特性進行內容劃分,由我從安全的角度丟擲GraphQL應該注意的幾點安全問題,而@圖南則會更多的從開發的角度給出他在實際使用過程中總結的最佳實踐。
另外,需要提前宣告的是,本文中我使用的後端開發語言是Go,@圖南使用的是Node.js,前端統一為React(GraphQL客戶端為Apollo),請大家自行消化。
Let’s Go!
GraphQL簡介
有些同學是不是根本沒聽過這個玩意?我們先來看看正在使用它的大客戶們:
是不是值得我們花幾分鐘對它做個簡單的瞭解了?XD
什麼是GraphQL
簡單的說,GraphQL是由Facebook創造並開源的一種用於API的查詢語言。
再引用官方文案來幫助大家理解一下GraphQL的特點:
1.請求你所要的資料,不多不少
向你的API發出一個GraphQL請求就能準確獲得你想要的資料,不多不少。GraphQL查詢總是返回可預測的結果。使用GraphQL的應用可以工作得又快又穩,因為控制資料的是應用,而不是伺服器。
2.獲取多個資源,只用一個請求
GraphQL查詢不僅能夠獲得資源的屬性,還能沿著資源間引用進一步查詢。典型的RESTful API請求多個資源時得載入多個URL,而GraphQL可以通過一次請求就獲取你應用所需的所有資料。
3.描述所有的可能,型別系統
GraphQL基於型別和欄位的方式進行組織,而非入口端點。你可以通過一個單一入口端點得到你所有的資料能力。GraphQL使用型別來保證應用只請求可能的資料,還提供了清晰的輔助性錯誤資訊。
GraphQL核心組成部分
1.Type
用於描述介面的抽象資料模型,有Scalar(標量)和Object(物件)兩種,Object由Field組成,同時Field也有自己的Type。
2.Schema
用於描述介面獲取資料的邏輯,類比RESTful中的每個獨立資源URI。
3.Query
用於描述介面的查詢型別,有Query(查詢)、Mutation(更改)和Subscription(訂閱)三種。
4.Resolver
用於描述介面中每個Query的解析邏輯,部分GraphQL引擎還提供Field細粒度的Resolver(想要詳細瞭解的同學請閱讀GraphQL官方文件)。
GraphQL VS. RESTful
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)
如果後端本身支援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後端只能支援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(); }
GraphQL注入
@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%E6%B3%A8%E5%85%A5/">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引擎在程式語言特性、實現方式等因素的影響下,都一定會有細微的差異。除了文章裡提到的這些內容,還可以對每個引擎內部的執行流程、語法解析、校驗和資料編解碼等環節進行審計,發掘更多有意思的內容。
不過我們的文章就先到這了,感謝閱讀!
參考
1. GraphQL Learn
2. GraphQL Fundamentals – Security
3. GraphQL – Security Overview and Testing Tips
4. A guide to authentication in GraphQL
5. Apollo Developer Guides – Security
6. Apollo Developer Guides – Access Control
7. GraphQL NoSQL Injection Through JSON Types
*本文作者 江南天安獵戶攻防實驗室,轉載請註明來自FreeBuf.COM