Depth & Complexity
เราได้ตกลงเรียบร้อยแล้วว่า ใคร เป็นคนถามและ เขาได้รับอนุญาตหรือไม่ ตอนนี้มาถึงคำถามข้อที่สาม: query นี้แพงแค่ไหน? คำขอหนึ่งอาจ authenticate ครบถ้วน, authorize เต็มที่ และยังทำให้ server ล้มลงคุกเข่าได้ — เพราะ GraphQL ให้ client เขียน รูปทรง ของงานเอง และ query string เล็ก ๆ สามารถอธิบายงานปริมาณมหาศาลได้
query เล็ก ๆ กลายเป็น denial of service ได้อย่างไร
หัวข้อที่มีชื่อว่า “query เล็ก ๆ กลายเป็น denial of service ได้อย่างไร”สองฟีเจอร์ของ graph ที่เชื่อมโยงกันทำให้การใช้ในทางที่ผิดเป็นเรื่องง่าย:
- การซ้อนลึก ถ้า
Userมีfriends: [User!]!แล้วuser → friends → friends → friends → …ก็เป็น SDL ที่ถูกต้องได้ไม่รู้จบ แต่ละชั้นทวีคูณจำนวน row ที่ server ต้องดึง สิบชั้นของ fan-out ขนาดปานกลางคือ object หลายล้านชิ้นจาก query ไม่กี่บรรทัด - Alias client อาจขอ field ที่แพงตัวเดิมหลายครั้งภายใต้ชื่อต่างกัน:
a: search(...) b: search(...) c: search(...)query ยังคงสั้น แต่ server รันงานหนึ่งครั้งต่อหนึ่ง alias
# Short to write, ruinous to execute: nesting plus aliasing.query Abuse { user(id: "1") { friends { # level 1 friends { # level 2 friends { # level 3 ... and on, and on a: posts { title } b: posts { title } # same field, billed twice via aliases } } } }}คุณแก้เรื่องนี้ด้วย authentication หรือ authorization ไม่ได้ เพราะผู้โจมตีอาจเป็น user ที่ถูกต้องตามกฎหมายและล็อกอินแล้ว ทางแก้คือวัด cost แล้วปฏิเสธสิ่งที่แพงเกินไปตั้งแต่ ก่อน execution
flowchart LR
P["parse: query string -> AST"] --> V["validate against schema"]
V --> Depth{"Depth within limit?"}
Depth -->|"No"| Rej["Reject (no resolver runs)"]
Depth -->|"Yes"| Cost{"Complexity within budget?"}
Cost -->|"No"| Rej
Cost -->|"Yes"| Exec["Execute with a timeout"]
Exec -->|"too slow"| Abort["Abort on timeout"]
Exec --> R["Response"] สามแนวป้องกัน เริ่มจากที่ถูกที่สุด
หัวข้อที่มีชื่อว่า “สามแนวป้องกัน เริ่มจากที่ถูกที่สุด”- Depth limiting เดินผ่าน query ที่ parse แล้ว และปฏิเสธถ้าการซ้อนเกินขีดสูงสุดที่กำหนด (เช่น 7) วิธีนี้หยาบแต่ถูก และจับการโจมตีแบบซ้อนเรียกซ้ำได้ทันที เป็นตัวควบคุมที่เพิ่มง่ายที่สุด และเป็นตัวที่เราจะคำนวณสด ๆ ด้านล่าง
- Complexity (cost) analysis กำหนด cost ให้แต่ละ field — ตัวเลขคงที่ หรือสูตรที่อิงกับ argument อย่าง
first: 100สำหรับ list ที่ทำ pagination — รวม cost ทั้ง query, คูณผ่านการซ้อน, และปฏิเสธอะไรก็ตามที่เกินงบประมาณ วิธีนี้จัดการได้ทั้ง query ที่กว้าง และ ลึก และคำนึงถึง alias เพราะแต่ละ alias เป็น selection แยกต่างหากที่บวกเข้ากับยอดรวม - Timeout เป็นกองหลังสำหรับอะไรก็ตามที่หลุดผ่าน static analysis: จำกัด wall-clock execution (และ call ต่อ data source) เพื่อให้ query ที่กลายเป็นว่าแพงตอน runtime ถูกยกเลิกแทนที่จะปล่อยให้รันไปตลอด
ไลบรารีอย่าง graphql-depth-limit และ graphql-query-complexity implement สองตัวแรกเป็น validation rule ดังนั้น query จึงถูกปฏิเสธระหว่าง validation — ไม่มี resolver ตัวใดรันเลย กฎ cost อ่านได้แบบนี้:
import { createComplexityRule, simpleEstimator } from 'graphql-query-complexity';
const complexityRule = createComplexityRule({ maximumComplexity: 1000, estimators: [ // A list field costs (childComplexity * the requested page size). simpleEstimator({ defaultComplexity: 1 }), ], onComplete: (cost) => { if (cost > 1000) throw new Error('Query is too complex: ' + cost); },});// Pass complexityRule in the server's validationRules so it runs before execution.คำนวณ depth จาก query ที่ parse แล้ว แบบสด ๆ
หัวข้อที่มีชื่อว่า “คำนวณ depth จาก query ที่ parse แล้ว แบบสด ๆ”Depth limiting implement ได้ในไม่กี่บรรทัด หัวใจคือ function parse ของ GraphQL ตัวเดียวกับที่ server ใช้ ซึ่งเปลี่ยน query string ให้เป็น AST (abstract syntax tree) เดินผ่าน selectionSet ทั้งหมด แล้วสายโซ่ของ selection ซ้อนที่ลึกที่สุดก็คือ depth ของ query เดโมด้านล่าง parse สาม query แล้ววัดทีละตัว จากนั้นใช้ limit เท่ากับ 5 กด Run
output จัดอันดับสาม query ตาม depth แล้วใช้ limit query ที่ตื้นและปานกลางผ่าน ส่วนตัวที่ใช้ในทางที่ผิด — ซ้อน friends ลึกห้าชั้น — เกิน limit ที่ 5 จึงถูกปฏิเสธ สังเกตว่า ไม่มี resolver ตัวไหนรันเลย เพราะเราทำงานบน AST ที่ parse แล้วล้วน ๆ เหมือนที่ validation rule ทำ query ที่เป็นอันตรายจึงถูกโยนทิ้งก่อนกินค่าใช้จ่ายใด ๆ ไลบรารี depth-limit ตัวจริงก็เดินแบบเดียวกัน ส่วน complexity analysis ต่อยอดด้วยการให้น้ำหนักแต่ละ field แทนที่จะนับแค่ชั้นการซ้อน
| ข้อดี | ข้อแลกเปลี่ยน |
|---|---|
| ป้องกัน malicious query ที่ nested ลึกมาก | threshold ที่ผิดอาจ reject legitimate query |
| complexity limit ป้องกัน expensive query ที่ fetch ข้อมูลมหาศาล | ต้องคำนวณ complexity weight ให้แม่นยำ |
| เพิ่มก่อน production — ปัญหา performance ชัดหลัง launch | complexity score ที่ซับซ้อนเกินยากต่อการ maintain |
| log rejected query เพื่อ tune limit | ต้อง communicate limit ให้ client รู้ล่วงหน้า |
ข้อผิดพลาดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ข้อผิดพลาดที่พบบ่อย”ไม่มี Depth หรือ Complexity Limit เลย อาการ:
- client ส่ง
{ users { posts { author { posts { author { posts { ... } } } } } } }ได้ - server CPU spike และ response time สูงมาก
- เพิ่ม
graphql-depth-limitหรือgraphql-query-complexityก่อน production
Limit ที่ Strict เกินโดยไม่ทดสอบ อาการ:
- depth limit 3 ทำให้ query ปกติของ client fail
- ต้อง test real-world query ก่อนตั้ง limit
- เริ่มจาก log + monitor ก่อน enforce hard limit
💡 ตัวอย่างจากของจริง
GitHub:
- GraphQL API มี rate limit ตาม complexity point
rateLimit { cost, remaining, resetAt }ใน response บอก client ว่าใช้ไปเท่าไรShopify:
- query cost limit ตาม “cost” ของแต่ละ field
- list field มี cost สูงกว่า scalar —
products(first: 100)แพงกว่าproduct { title }