SecLab

GraphQL API vulnerabilities

API1API4CWE-639CWE-770
01

Là gì

GraphQL là một lớp truy vấn cho phép client tự chọn hình dạng dữ liệu nó nhận. Vấn đề bảo mật đến từ chính sự linh hoạt đó: client quyết định câu truy vấn, nên bề mặt tấn công là toàn bộ graph — không phải một tập endpoint cố định — và phân quyền, giới hạn tài nguyên phải áp ở tầng field, không ở tầng route.

02

Vì sao bạn quan tâm

Mức liên quan: Bắt buộcKỳ vọng: L2

GraphQL không tạo ra lỗ hổng mới; nó dịch chuyển các lỗ hổng cũ tới một tầng mà công cụ và thói quen cũ không với tới. Ba dịch chuyển đáng nắm:

  • Phân quyền chuyển từ route sang FIELD. REST có GET /orders/{id} để gắn một phép kiểm; GraphQL có một câu query lấy user { orders { paymentDetails } }, và mỗi field trong đó cần phân quyền riêng. Một field resolver quên kiểm là một BOLA/IDOR (topic access-control) mà không route nào đại diện.
  • Giới hạn tài nguyên chuyển từ pageSize sang ĐỘ SÂU/ĐỘ PHỨC TẠP query. Một câu query lồng nhau posts { author { posts { author { ... } } } } là một DoS — nó nở theo cấp số nhân mà không có tham số pageSize nào để giới hạn (topic api-security API4).
  • Introspection và các field lỗi rò cấu trúc. GraphQL mặc định cho phép hỏi "graph có những gì" (introspection), và thông báo lỗi gợi ý tên field — cả hai là information disclosure (topic info-disclosure).

Và một điều riêng của GraphQL: batching phá rate limit theo request. Một HTTP request GraphQL có thể chứa hàng nghìn thao tác (alias hoặc batch), nên "giới hạn 100 request/phút" trở thành 100×N thao tác — đủ để brute-force một mã OTP trong một request.

Nói cách khác: nếu bạn đã đọc access-control, api-security, và info-disclosure, bạn đã biết các lỗ hổng — topic này là về việc chúng trông thế nào khi client cầm câu truy vấn.

03

Cơ chế hoạt động

Cơ chế là: client gửi một câu truy vấn, server chạy một cây resolver — một hàm cho mỗi field. Bề mặt tấn công là cây đó, không phải một danh sách endpoint.

Nguồn sơ đồ
flowchart TD    Q["Query client tự soạn<br/>user(id:7) { email, orders { paymentDetails } }"] --> R{Resolver mỗi field}    R --> F1["user resolver — có kiểm quyền?"]    F1 --> F2["orders resolver — có kiểm ownership?"]    F2 --> F3["paymentDetails resolver — có kiểm field-level?"]    F3 -->|"một field quên kiểm"| B["🔓 BOLA/IDOR ở tầng field"]    Q --> D["Query lồng sâu / batch nghìn thao tác"]    D --> DoS["🔓 DoS: nở cấp số nhân, vòng qua rate limit"]

Điểm cốt lõi: không có một route để gắn phép kiểm. Trong REST, paymentDetails là một endpoint riêng với [Authorize] của nó; trong GraphQL nó là một field trong một câu query lớn hơn, và phép kiểm phải nằm trong resolver của chính field đó — nếu không, một query khéo léo lấy được nó.

Bảng vấn đề và ánh xạ sang topic khác:

Vấn đềCơ chếLà topic
Field-level authz thiếuResolver không kiểm ownership/roleaccess-control (API1)
Query depth/complexity DoSLồng sâu, nở cấp số nhânapi-security (API4), CWE-770
Batching vòng qua rate limitNghìn thao tác trong một requestrate-limiting
Introspection rò schema__schema trả toàn bộ cấu trúcinfo-disclosure
Field gợi ý qua lỗi"Did you mean...?" rò tên fieldinfo-disclosure
Injection trong đối sốĐối số resolver vào SQL/NoSQLsql-injection

Hàng "batching" đáng nói riêng vì nó phản trực giác: rate limit theo HTTP request là vô nghĩa với GraphQL, vì một request chứa được [{login}, {login}, {login}, …] nghìn lần bằng alias — nên OTP brute-force xong trong một request, dưới ngưỡng mọi rate limit theo request.

Mô tả sơ đồ: Sơ đồ cho một câu query GraphQL do client tự soạn lấy user(id:7) với email và orders với paymentDetails. Server chạy một cây resolver, một hàm cho mỗi field: user resolver, rồi orders resolver, rồi paymentDetails resolver. Mỗi field cần phân quyền riêng; nếu một field quên kiểm thì đó là một BOLA hoặc IDOR ở tầng field mà không route nào đại diện. Một nhánh khác cho thấy query lồng sâu hoặc batch nghìn thao tác dẫn tới DoS nở theo cấp số nhân và vòng qua rate limit theo request.

04

Ví dụ cụ thể

Cùng một schema, ba đường tấn công.

graphql
# ① Field-level authz thiếu. paymentDetails không kiểm ownership.query {  user(id: 7) {           # user 7 KHÔNG phải người gọi    email    paymentDetails { cardLast4, billingAddress }   # resolver quên kiểm → rò  }}
graphql
# ② Query depth DoS. Lồng qua lại author↔posts, nở theo cấp số nhân.query {  posts { author { posts { author { posts { author { posts { id } } } } } } }}# Mỗi cấp nhân số node lên; 8 cấp là hàng triệu resolver chạy → server hết CPU/RAM.
graphql
# ③ Batching vòng qua rate limit. Một HTTP request, nghìn lần thử OTP bằng alias.mutation {  a: verifyOtp(code: "0000") { ok }  b: verifyOtp(code: "0001") { ok }  c: verifyOtp(code: "0002") { ok }  # ... 10000 alias nữa. Rate limit "100 request/phút" thấy ĐÚNG MỘT request.}
JSON
// ④ Introspection rò toàn bộ schema (bật theo mặc định ở nhiều server).{"query":"{ __schema { types { name fields { name } } } }"}// → trả về mọi type, mọi field, kể cả field nội bộ chưa ai định công khai.
# Sau khi vá: authz mỗi resolver, trần độ sâu + độ phức tạp, batch giới hạn,# introspection tắt ở production, thông báo lỗi không gợi ý field.
C#Resolver không kiểm ownership, và server không giới hạn độ sâu hay batch.
// HotChocolate (GraphQL cho .NET).public class Query{    // ❌ Field-level authz thiếu. Resolver này trả user theo id BẤT KỲ, không kiểm    //    người gọi có quyền xem không. Trong REST đây là GET /users/{id} với    //    [Authorize] của nó; ở GraphQL nó là một field, và phép kiểm phải ở ĐÂY.    public async Task<User?> GetUser(int id, [Service] IUserRepository users)        => await users.GetByIdAsync(id);   // không có userId của người gọi → BOLA} public class UserType : ObjectType<User>{    protected override void Configure(IObjectTypeDescriptor<User> d)    {        // ❌ paymentDetails là field nhạy cảm nhưng resolver của nó không kiểm role.        //    Một query user(id:7){ paymentDetails } lấy được thẻ của người khác.        d.Field(u => u.PaymentDetails);    }} var builder = WebApplication.CreateBuilder(args);builder.Services    .AddGraphQLServer()    .AddQueryType<Query>();    // ❌ Không MaxExecutionDepth, không cost analysis, không giới hạn batch. Một query    //    lồng 20 cấp hay một batch 1000 alias verifyOtp chạy tự do.    // ❌ Và introspection bật theo mặc định ở production. var app = builder.Build();app.MapGraphQL();
05

Chuyện đã xảy ra

Họ lỗ hổng field-level authz trong bug bounty (nhiều báo cáo). Mẫu lặp lại: một app REST thêm GraphQL cho tính năng mới, phân quyền được áp cẩn thận ở các route REST nhưng resolver GraphQL bỏ sót — nên cùng dữ liệu lấy được qua graph mà không qua route. Đáng nhớ vì nó cho thấy GraphQL không phải "an toàn hơn" hay "kém hơn" REST — nó chỉ chuyển phép kiểm sang một chỗ mà đội quen với REST không nhìn.

Batching brute-force OTP/2FA (nhiều báo cáo, ~2018–nay). Các báo cáo mô tả cùng kỹ thuật: gói hàng nghìn verifyOtp bằng alias trong một mutation, nên brute-force một mã sáu số xong trong một HTTP request — dưới ngưỡng mọi rate limit theo request. Đây là nguồn của khái niệm batching ở khối 3.

DoS qua query lồng sâu (nhiều CVE trong GraphQL server). Nhiều server không giới hạn độ sâu/độ phức tạp theo mặc định, nên một câu query lồng qua lại giữa hai type có quan hệ hai chiều làm hết tài nguyên. Minh hoạ CWE-770 và luận điểm API4: không có tham số pageSize thì không có gì tự giới hạn.

06

Cách phòng chống

Lớp 1

Phân quyền ở TỪNG resolver, không ở tầng route

bắt buộc

GraphQL không có route để gắn [Authorize], nên phân quyền phải ở resolver của field — đúng cùng nguyên tắc topic access-control, chỉ ở một tầng khác.

  • Mỗi resolver trả dữ liệu thuộc phạm vi người dùng phải kiểm ownership, đúng như GetByIdAsync(id, userId) ở topic access-control. Một resolver orders phải lọc theo người gọi, không trả mọi đơn.
  • Field nhạy cảm kiểm role ngay trong resolver. paymentDetails, internalNotes cần role, và phép kiểm nằm ở resolver của chính field đó — không ở resolver cha.
  • Đừng dựa vào việc UI không hỏi field đó. Client cầm câu truy vấn, nên mọi field trong schema là công khai truy vấn được. Cùng bài học API3/API9 ở topic api-security.

Cách làm bền: phân quyền ở tầng dữ liệu, không ở resolver rải rác — một data loader áp ownership khi tải, hoặc RLS ở DB (topic access-control lớp 2), để một resolver mới quên kiểm vẫn không trả được dữ liệu người khác. Rải if (user.canSee(...)) khắp resolver là mặc-định-MỞ, và resolver thứ 41 sẽ thiếu.

C# · Layer 1Authz mỗi resolver, trần độ sâu + độ phức tạp, introspection tắt ở production.
public class Query{    // Field-level authz: resolver nhận người gọi từ context và lọc theo ownership —    // đúng cùng nguyên tắc GetByIdAsync(id, userId) ở topic access-control.    public async Task<User?> GetUser(        int id,        [Service] IUserRepository users,        [GlobalState] CurrentUser caller)    {        // Người dùng thường chỉ xem được chính mình; admin xem được người khác.        if (id != caller.Id && caller.Role < SystemRole.Admin)            return null;   // 404-tương-đương: không xác nhận sự tồn tại (topic access-control)        return await users.GetByIdAsync(id, caller.Id);    }} public class UserType : ObjectType<User>{    protected override void Configure(IObjectTypeDescriptor<User> d)    {        // Field nhạy cảm kiểm quyền TRONG resolver của chính field đó — không ở resolver cha.        d.Field(u => u.PaymentDetails)            .Authorize("SelfOrAdmin");   // policy: chủ tài khoản hoặc admin    }} var builder = WebApplication.CreateBuilder(args);builder.Services    .AddGraphQLServer()    .AddQueryType<Query>()    .AddAuthorization()    // Trần độ sâu: chặn query lồng qua lại author↔posts nở cấp số nhân.    .ModifyRequestOptions(o => o.Complexity.Enable = true)    .AddMaxExecutionDepthRule(10)    // Trần độ phức tạp: gán chi phí, từ chối query vượt ngân sách. Mạnh hơn trần độ    // sâu vì nó bắt cả query nông mà rộng, và nó là cơ sở cho rate limit theo chi phí.    .ModifyRequestOptions(o =>    {        o.Complexity.MaximumAllowed = 1000;        o.ExecutionTimeout = TimeSpan.FromSeconds(10);   // query chạy lâu cũng là DoS    }); var app = builder.Build(); // Introspection và GraphiQL CHỈ ở Development — cùng mẫu Swagger ở topic info-disclosure.app.MapGraphQL().WithOptions(new GraphQLServerOptions{    Tool = { Enable = app.Environment.IsDevelopment() },    EnableSchemaRequests = app.Environment.IsDevelopment(),});
Lớp 1b

Trần độ sâu, độ phức tạp, và batch — client cầm câu truy vấn nên phải giới hạn nó

bắt buộc

REST giới hạn bằng pageSize; GraphQL không có nó, nên phải giới hạn chính câu truy vấn (topic api-security API4, CWE-770):

  • Trần độ sâu (ví dụ 10 cấp). Chặn query lồng qua lại author↔posts nở theo cấp số nhân.
  • Trần độ phức tạp: gán chi phí cho mỗi field (list tốn hơn scalar) và từ chối query vượt ngân sách. Đây là biện pháp mạnh hơn trần độ sâu vì nó bắt cả query nông mà rộng.
  • Giới hạn batch và alias: đây là biện pháp riêng của GraphQL và hay bị bỏ. Giới hạn số thao tác trong một request và số alias cùng tên một field — nếu không, batching vòng qua mọi rate limit (ví dụ ③ ở khối 4: brute-force OTP trong một request).
  • Trần số node trả về (giống pageSize): một list không phân trang là một list không giới hạn.

Cộng timeout cho toàn bộ query: một query đủ phức tạp để chạy 30 giây là một DoS mà trần độ sâu có thể cho qua. Dùng thư viện đã có (graphql-depth-limit, graphql-cost-analysis) thay vì tự viết — tính chi phí đúng là khó.

Lớp 1c

Tắt introspection và gợi ý field ở production

Đây là biện pháp information disclosure (topic info-disclosure), áp vào GraphQL.

  • Tắt introspection ở production. __schema__type trả về toàn bộ cấu trúc graph — mọi type, mọi field, kể cả field nội bộ. Nó hữu ích lúc dev (GraphiQL cần nó), nên bật ở Development và tắt ở Production — cùng mẫu với Swagger ở topic info-disclosure.
  • Tắt "did you mean" trong thông báo lỗi. Nhiều GraphQL server gợi ý tên field gần đúng khi client gõ sai — đó là introspection qua cửa sau. Trả lỗi generic ở production.
  • Không trả stack trace trong lỗi resolver — cùng bản vá correlation-id ở topic info-disclosure.

Lưu ý về mức độ: tắt introspection không phải một biện pháp bảo mật thật, nó chỉ làm chậm — kẻ tấn công vẫn đoán được field qua "did you mean" hoặc thử. Nó là lớp 1c chứ không phải lớp 1: phân quyền field-level (lớp 1) mới là thứ chặn, tắt introspection chỉ giảm bề mặt trinh sát.

Lớp 2

Rate limit theo CHI PHÍ query, không theo request; và đối số là input không tin cậy

Batching làm rate limit theo request vô nghĩa (khối 3), nên rate limit phải tính theo thứ khác.

  • Rate limit theo CHI PHÍ, không theo request. Gán ngân sách chi phí cho mỗi người dùng mỗi phút, và trừ theo độ phức tạp thật của mỗi query (cùng công thức với trần độ phức tạp ở lớp 1). Một request chứa nghìn thao tác tốn nghìn lần ngân sách — nên nó bị chặn ở đúng chỗ mà "100 request/phút" bỏ lọt.
  • Đối số resolver là input không tin cậy. Một đối số filter: "...") đi vào SQL là SQL injection (topic sql-injection); một đối số url mà resolver fetch là SSRF (topic ssrf). GraphQL không làm đối số đáng tin hơn query param.

Và persisted queries (allowlist các query đã biết) là biện pháp mạnh khi client là của chính bạn: server chỉ chạy các query trong danh sách đã đăng ký, nên độ sâu, độ phức tạp, và batch đều bị giới hạn bởi chính danh sách đó — client không soạn được query tuỳ ý.

Lớp 3

Phát hiện: query bất thường về hình dạng là tín hiệu

Client hợp lệ của bạn gửi một tập query có hình dạng khá cố định (nhất là với persisted queries), nên lệch khỏi hình dạng đó là tín hiệu.

  • Query vượt trần độ sâu/độ phức tạp bị từ chối → log chúng. Một chuỗi query sát trần là một lần dò tìm giới hạn.
  • Request có số alias/thao tác bất thường → chữ ký của batching brute-force. Một mutation có 1000 alias verifyOtp không phải một client thật.
  • Truy vấn __schema/__type ở production → introspection đang bị dò (nếu bạn chưa tắt nó, đây cũng là lời nhắc tắt).
  • Một field nhạy cảm bị truy vấn với id không thuộc người gọi nhiều lần → BOLA đang bị dò, cùng phát hiện với topic access-control (nhiều 404/403 field-level).

Đây là lớp 3 vì lớp 1 đã chặn; log biến "đã chặn" thành "biết ai đang thử".

07

Kiểm chứng đã vá

1. Test field-level authz — mỗi field nhạy cảm, một test cross-user. Xem tab csharp / test. Cùng quy ước với topic access-control: gửi một query lấy field của người khác và khẳng định nó không trả dữ liệu. Đây là phép kiểm quan trọng nhất, vì field-level authz là lỗ hổng phổ biến nhất của GraphQL.

2. Test trần độ sâu và độ phức tạp:

Shell
B=https://staging.example.com/graphql# Query lồng 20 cấp phải bị từ chối trước khi chạy (không phải chạy rồi timeout).Q=$(python3 -c "print(\"query{\"+\"posts{author{\"*20+\"id\"+\"}}\"*20+\"}\")")curl -s -o /dev/null -w '%{http_code}\n' "$B" -H 'Content-Type: application/json' \  -d "{\"query\":\"$Q\"}"   # phải là 400, và nhanh

3. Test batching bị giới hạn — đây là phép kiểm riêng của GraphQL, ít ai viết:

Shell
# Một mutation với 1000 alias verifyOtp phải bị từ chối, không chạy hết.python3 -c "print('mutation{'+''.join(f'a{i}:verifyOtp(code:\\\"{i:04d}\\\"){{ok}}' for i in range(1000))+'}')" > batch.txtcurl -s -o /dev/null -w '%{http_code}\n' "$B" -H 'Content-Type: application/json' \  --data-binary "{\"query\":\"$(cat batch.txt)\"}"   # phải là 400

4. Kiểm introspection tắt ở production:

Shell
curl -s "$B" -H 'Content-Type: application/json' \  -d '{"query":"{ __schema { types { name } } }"}' \  | grep -q '__schema' && { echo "introspection còn bật ở production"; exit 1; }exit 0

5. Kiểm thông báo lỗi không gợi ý field — gõ một field sai và khẳng định response không chứa "Did you mean". Đó là introspection qua cửa sau.

6. Kiểm đối số resolver qua đúng phép kiểm injection — một đối số filter vào truy vấn DB phải tham số hoá (topic sql-injection), một đối số url resolver fetch phải qua allowlist (topic ssrf).

TypeScriptField-level cross-user, trần độ sâu, và batching — ba phép kiểm cốt lõi của GraphQL.
import { test, expect } from "vitest"; const GQL = "http://localhost:5100/graphql"; async function gql(query: string, cookie: string) {  const res = await fetch(GQL, {    method: "POST",    headers: { "Content-Type": "application/json", Cookie: cookie },    body: JSON.stringify({ query }),  });  return { status: res.status, body: await res.json() };} // Field-level authz — phép kiểm quan trọng nhất, vì đây là lỗ hổng GraphQL phổ biến// nhất. Cùng quy ước cross-user với topic access-control.test("a user cannot read another user's payment details", async () => {  const bob = await signInAs("bob");  const aliceId = await seedUser("alice");   const { body } = await gql("{ user(id: " + aliceId + ") { paymentDetails { cardLast4 } } }", bob);   // Không trả dữ liệu của Alice — hoặc null, hoặc lỗi authz, KHÔNG phải thẻ của cô ấy.  expect(body.data?.user?.paymentDetails ?? null).toBeNull();}); // Trần độ sâu: query lồng 20 cấp phải bị TỪ CHỐI trước khi chạy, không chạy rồi timeout.test("a deeply nested query is rejected", async () => {  const bob = await signInAs("bob");  const deep = "query{" + "posts{author{".repeat(20) + "id" + "}}".repeat(20) + "}";   const { body } = await gql(deep, bob);   expect(body.errors?.[0]?.message).toMatch(/depth|complexity/i);  expect(body.data).toBeUndefined();}); // Batching — phép kiểm RIÊNG của GraphQL, ít ai viết. Một mutation với nghìn alias// verifyOtp là brute-force trong một request, dưới ngưỡng rate limit theo request.test("thousands of aliased operations are refused", async () => {  const bob = await signInAs("bob");  const aliases = Array.from({ length: 1000 }, (_, i) =>    "a" + i + ': verifyOtp(code: "' + String(i).padStart(4, "0") + '") { ok }').join(" ");   const { status, body } = await gql("mutation { " + aliases + " }", bob);   expect(status).toBe(400);  // Và KHÔNG có alias nào thực thi — không mã OTP nào bị thử.  expect(body.data).toBeUndefined();}); // Introspection tắt ở production build.test("introspection is disabled in production", async () => {  const { body } = await gql("{ __schema { types { name } } }", "");  expect(body.data?.__schema).toBeUndefined();});
08

Sai lầm thường gặp

"Bản vá"Vì sao không đúng
Phân quyền ở endpoint /graphqlChỉ một endpoint, nhưng bề mặt là mọi field. Phân quyền phải ở từng resolver
Dựa vào việc UI không hỏi field nhạy cảmClient cầm câu truy vấn. Mọi field trong schema là công khai truy vấn được (API3)
Rate limit theo HTTP requestBatching gói nghìn thao tác trong một request. Rate limit theo CHI PHÍ query
Chỉ trần độ sâuQuery nông mà rộng vẫn là DoS. Cần trần độ PHỨC TẠP nữa
Tắt introspection rồi coi là an toànNó chỉ làm chậm. "Did you mean" và thử vẫn lộ field. Field-level authz mới chặn
Tin đối số resolverĐối số vào SQL là injection, vào fetch là SSRF. Đối số là input không tin cậy
Rải if (canSee) khắp resolverMặc-định-MỞ; resolver thứ 41 quên. Phân quyền ở tầng dữ liệu / data loader
"GraphQL an toàn hơn REST"Nó không an toàn hơn hay kém hơn — nó chuyển lỗ hổng sang tầng field mà thói quen REST không nhìn

Sai lầm về mô hình, và nó là sai lầm chính: mang tư duy REST (một route, một phép kiểm) sang GraphQL. GraphQL có MỘT endpoint và VÔ SỐ đường truy vấn, nên phép kiểm phải ở tầng field, và giới hạn tài nguyên phải ở tầng hình dạng query — không phải tầng route.

Sai lầm về phân loại: coi GraphQL là một topic riêng cần kỹ thuật mới. Các lỗ hổng của nó LÀ access-control, api-security, info-disclosure, sql-injection, ssrf — chỉ nhìn từ một tầng khác. Nếu đã đọc các topic đó, việc còn lại là áp chúng ở tầng resolver.

09

Nguồn tham khảo

Bậc 1API4:2023 Unrestricted Resource Consumption · OWASP · API Security Top 10 · 2023
Bậc 1GraphQL Specification — Introspection · GraphQL Foundation · GraphQL spec · October 2021
Bậc 2GraphQL API vulnerabilities · PortSwigger · Web Security Academy
Bậc 2GraphQL Cheat Sheet · OWASP · Cheat Sheet Series
Bậc 2API3:2023 Broken Object Property Level Authorization · OWASP · API Security Top 10 · 2023
Nằm trong lộ trình
Secure Backend DeveloperXem lộ trình

Bình luận

Tham gia thảo luận
Đăng ký để bình luận

Bình luận cần tài khoản đã hoàn thành ít nhất một bài học. Điều kiện đó là thứ giữ cho luồng thảo luận này còn đáng đọc: mỗi ý kiến gắn với một người có thể bị hỏi lại, và reputation tích luỹ theo thời gian.

Đăng kýĐăng nhập

Bạn vẫn đọc được toàn bộ bình luận dưới đây mà không cần tài khoản. Đăng nhập xong bạn sẽ quay lại đúng chỗ này, không phải đầu trang.

Đang tải bình luận…