ข้ามไปยังเนื้อหา

Depth & Complexity

เราได้ตกลงเรียบร้อยแล้วว่า ใคร เป็นคนถามและ เขาได้รับอนุญาตหรือไม่ ตอนนี้มาถึงคำถามข้อที่สาม: query นี้แพงแค่ไหน? คำขอหนึ่งอาจ authenticate ครบถ้วน, authorize เต็มที่ และยังทำให้ server ล้มลงคุกเข่าได้ — เพราะ GraphQL ให้ client เขียน รูปทรง ของงานเอง และ query string เล็ก ๆ สามารถอธิบายงานปริมาณมหาศาลได้

สองฟีเจอร์ของ 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"]
การตรวจ cost รันหลัง parsing และ validation แต่ก่อนที่ resolver จะแตะ data source ใด ๆ
  1. Depth limiting เดินผ่าน query ที่ parse แล้ว และปฏิเสธถ้าการซ้อนเกินขีดสูงสุดที่กำหนด (เช่น 7) วิธีนี้หยาบแต่ถูก และจับการโจมตีแบบซ้อนเรียกซ้ำได้ทันที เป็นตัวควบคุมที่เพิ่มง่ายที่สุด และเป็นตัวที่เราจะคำนวณสด ๆ ด้านล่าง
  2. Complexity (cost) analysis กำหนด cost ให้แต่ละ field — ตัวเลขคงที่ หรือสูตรที่อิงกับ argument อย่าง first: 100 สำหรับ list ที่ทำ pagination — รวม cost ทั้ง query, คูณผ่านการซ้อน, และปฏิเสธอะไรก็ตามที่เกินงบประมาณ วิธีนี้จัดการได้ทั้ง query ที่กว้าง และ ลึก และคำนึงถึง alias เพราะแต่ละ alias เป็น selection แยกต่างหากที่บวกเข้ากับยอดรวม
  3. 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 limiting implement ได้ในไม่กี่บรรทัด หัวใจคือ function parse ของ GraphQL ตัวเดียวกับที่ server ใช้ ซึ่งเปลี่ยน query string ให้เป็น AST (abstract syntax tree) เดินผ่าน selectionSet ทั้งหมด แล้วสายโซ่ของ selection ซ้อนที่ลึกที่สุดก็คือ depth ของ query เดโมด้านล่าง parse สาม query แล้ววัดทีละตัว จากนั้นใช้ limit เท่ากับ 5 กด Run

JavaScript

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 ชัดหลัง launchcomplexity 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 }
ทำไม query string สั้น ๆ จึงทำให้เกิด denial of service ใน GraphQL ได้?
จริง ๆ แล้ว depth limiting ตรวจสอบอะไร?
ณ จุดใดที่กฎ depth และ complexity ปฏิเสธ query ที่เกินงบประมาณ?
ทำไม complexity (cost) analysis จึงทรงพลังกว่า depth limiting เพียงอย่างเดียว?