Bảo mật GraphQL — query sâu, alias, DoS ngay ở bước validate, CSRF, và phân quyền theo field

GraphQL đưa cho client một ngôn ngữ truy vấn trên đồ thị dữ liệu của bạn. Kẻ tấn công cũng là client. Với REST, bề mặt tấn công là danh sách endpoint cố định; với GraphQL, nó là mọi tổ hợp query có thể viết được trên schema. Bài này tái hiện từng kiểu tấn công trên graphql-js 17, Apollo Server 5 và Lighthouse 6 (Laravel), đo cái giá thật (thời gian, RAM, process chết), rồi dựng các lớp phòng thủ theo thứ tự rẻ → đắt.

Thuật ngữ dùng trong bài
DoS (Denial of Service — làm server quá tải để người dùng thật không được phục vụ) · depth limit (giới hạn số tầng lồng nhau của query) · cost / complexity analysis (tính "giá" của query trước khi chạy, dựa trên số field và tham số như first) · alias (gọi cùng một field nhiều lần dưới tên khác trong một query) · batching (nhiều operation trong một HTTP request — dạng mảng JSON hoặc dạng alias) · introspection (query __schema trả cả schema) · field suggestion (lỗi validate kèm "Did you mean …?" — lộ tên field thật) · trusted documents / persisted operations (server chỉ chạy những query đã đăng ký trước — allowlist) · APQ (Automatic Persisted Queries — cache query theo hash để tiết kiệm băng thông; không phải allowlist) · CSRF (Cross-Site Request Forgery — trang lạ khiến trình duyệt nạn nhân gửi request kèm cookie) · preflight (request OPTIONS trình duyệt tự gửi trước các request "không đơn giản" — cơ chế CORS) · IDOR (Insecure Direct Object Reference — đổi id là đọc được object của người khác) · error masking (che message lỗi nội bộ).
Kiểm chứng: tài liệu qua Context7: graphql.org (learn/security — depth, breadth/batch limiting, trusted documents), graphql-js 17 (validate() với hideSuggestions, maxErrors; parse() với maxTokens), Lighthouse (security, @complexity), Apollo Server (APQ). Đọc trực tiếp: Apollo Server docs (options), OWASP GraphQL Cheat Sheet, PortSwigger Web Security Academy (GraphQL), GitHub Docs (GraphQL rate limits), GitLab patch release 18.6.2 (CVE-2025-12562); source Lighthouse 6.71.0 (ComplexityDirective, PaginateDirective) và webonyx FieldsOnCorrectType. Lab chạy thật: graphql-js 17.0.2 và 16.14.2 trên Node 22.23.3; Apollo Server 5.5.1; Laravel 13.34.0 + Lighthouse 6.71.0 + webonyx/graphql-php 15.37.2 + PHP 8.3.33 + PostgreSQL 18.4. Ngày đọc nguồn ngoài: 01/10/2026.

Trả lời 30 giây

Với GraphQL, client tự quyết chi phí của mỗi request, nên server phải định giá query trước khi chạy và chặn ở tầng rẻ nhất có thể: giới hạn kích thước và số token lúc parse; giới hạn độ sâu, chi phí, số alias lúc validate; timeout và trần phân trang lúc execute; che message lỗi lúc trả về. Với app của chính mình, cách mạnh nhất là trusted documents — server chỉ chạy những query đã đăng ký lúc build. Hai điều người ta hay quên: (1) rate limit theo số HTTP request vô dụng, vì một request chứa được 10 000 lần thử OTP bằng alias — lab của tôi tìm ra mã trong 1 request; (2) phân quyền phải đặt ở field/object, không phải ở "cửa vào", vì cùng một field email tới được từ nhiều đường. Thêm một điểm ít ai biết: ngay bước validate của graphql-js cũng bị DoS — một query 23 KB tốn 11 giây và 1,2 GB RAM trước khi resolver nào chạy, nên giới hạn token phải đặt trước validate.

1. Bài toán gốc và bối cảnh ra đời

Ở REST, mỗi endpoint có chi phí gần như cố định; WAF, rate limit, log đều làm việc theo URL. GraphQL gom mọi thứ vào POST /graphql và để client tự viết query, nên ba giả định cũ cùng vỡ:

Hệ quả có thật ngoài đời: GitLab vá CVE-2025-12562 (CVSS 7.5) trong bản 18.6.2 — "an unauthenticated user to create a denial of service condition by sending crafted GraphQL queries that bypass query complexity limits", ảnh hưởng từ bản 11.10; trước đó là CVE-2025-8014 cùng loại. graphql-java có CVE-2022-37734 (DoS bằng query độc hại ăn CPU, vá ở 19.0/18.3/17.4). GitHub mở GraphQL API công khai và phải dựng hẳn một mô hình chi phí (mục 9). Bảo mật GraphQL không phải chuyện lý thuyết.

2. Mô hình tư duy cốt lõi

  1. Định giá trước khi chạy, chặn ở tầng rẻ nhất. Mỗi tầng xử lý request (HTTP → parse → validate → execute → trả lỗi) đắt hơn tầng trước. Query độc hại phải bị loại càng sớm càng tốt.
  2. Quyền gắn vào dữ liệu, không gắn vào cửa vào. Một field tới được từ N đường; chỉ kiểm tra ở một đường là để hở N−1 đường còn lại.
Các lớp phòng thủ và kiểu tấn công bị chặn ở từng lớp Năm kiểu request đi qua các lớp HTTP, parse, validate, execute, trả lỗi. Query lặp field 4000 lần bị chặn ở parse vì vượt số token. Query lồng 5 tầng bị chặn ở validate vì quá sâu. Query first 100 nhân 100 và query 10000 alias bị chặn ở validate vì vượt chi phí. Query đọc email qua post.author đi qua tới execute, ở đó field email trả null vì không có quyền, response vẫn hợp lệ. HTTPbody, rate parsemaxTokens validatesâu · chi phí · alias executequyền field · timeout trả lỗiche message client f0 × 4000 lồng 5 tầng first:100×100 10 000 alias post.author .email ✕quá 2000 token ✕sâu 6 > 4 ✕20 202 > 1 000 điểm ✕chi phí / số alias email: null200, data hợp lệ
Hình 1 — Mỗi kiểu tấn công bị chặn ở lớp rẻ nhất có thể (số liệu từ lab 04). Lớp HTTP (giới hạn body) gần như không chặn được gì ở đây: query lặp field chỉ 12 KB, query lồng 5 tầng chỉ 109 ký tự. Request cuối không phải tấn công bị chặn mà là truy cập hợp lệ: nó đi hết đường, chỉ field email bị che vì người xem không có quyền.

3. Internals và quyết định thiết kế — từng kiểu tấn công, từng cách chặn

3.1 Query sâu trên đồ thị có vòng

Schema mạng xã hội có User.friends: [User!]! — đồ thị có vòng, nên query lồng bao nhiêu tầng cũng hợp lệ. Mỗi user có 10 friend:

friends lồng 1 tầng: query  41 ký tự →     10 user ở tầng cuối | friends resolver     1 lần | response    0.2 KB |    2ms
friends lồng 2 tầng: query  58 ký tự →    100 user ở tầng cuối | friends resolver    11 lần | response    2.3 KB |    3ms
friends lồng 3 tầng: query  75 ký tự →   1000 user ở tầng cuối | friends resolver   111 lần | response   22.9 KB |   24ms
friends lồng 4 tầng: query  92 ký tự →  10000 user ở tầng cuối | friends resolver  1111 lần | response  228.9 KB |   37ms
friends lồng 5 tầng: query 109 ký tự → 100000 user ở tầng cuối | friends resolver 11111 lần | response 2288.9 KB |  280ms
Query dài thêm tuyến tính, response tăng theo hàm mũ Mỗi tầng lồng thêm, query chỉ dài thêm 17 ký tự nhưng số user trả về nhân 10: từ 10 user 0,2 KB ở tầng 1 lên 100 nghìn user 2,2 MB ở tầng 5. Trục ngang theo thang log. Mỗi tầng: query +17 ký tự, response ×10 (trục ngang thang log10) lồng 1 tầnglồng 2 tầnglồng 3 tầng lồng 4 tầnglồng 5 tầng 41 kt58 kt75 kt92 kt109 kt 10 user · 0,2 KB 100 · 2,3 KB 1 000 · 22,9 KB · 24ms 10 000 · 229 KB · 37ms 100 000 · 2,2 MB · 280ms 1101001k10k100k
Hình 2 — Số liệu lab 04 mục A (graphql-js 17, dữ liệu trong RAM, chưa đụng DB). Với DB thật và DataLoader, mỗi tầng thêm 1 câu SQL nhưng câu đó to gấp 10 lần câu trước. Tầng 6 là 1 triệu object — một request đủ làm một process Node bận vài giây.

Chặn: depth limit — một validation rule. Viết tay chừng 20 dòng để hiểu nó làm gì (thực tế dùng thư viện có sẵn):

function depthLimitRule(max) {
  return (context) => {
    const fragments = Object.fromEntries(context.getDocument().definitions
      .filter((d) => d.kind === Kind.FRAGMENT_DEFINITION).map((d) => [d.name.value, d]));
    const depth = (set, seen) => Math.max(0, ...set.selections.map((sel) => {
      if (sel.kind === Kind.FIELD) return sel.selectionSet ? 1 + depth(sel.selectionSet, seen) : 0;
      if (sel.kind === Kind.INLINE_FRAGMENT) return depth(sel.selectionSet, seen);
      const name = sel.name.value;                       // FRAGMENT_SPREAD: đi xuyên vào fragment, chống vòng
      return seen.has(name) ? 0 : depth(fragments[name].selectionSet, new Set([...seen, name]));
    }));
    return {
      OperationDefinition(op) {
        const d = depth(op.selectionSet, new Set());
        if (d > max) context.reportError(new GraphQLError(`Query sâu ${d} tầng, giới hạn là ${max}.`, { nodes: [op] }));
      },
    };
  };
}

Bẫy của depth limit tự viết: quên đi xuyên fragment — kẻ tấn công giấu cả 5 tầng trong một fragment. Lab có ca này và rule trên bắt được ("Query sâu 6 tầng, giới hạn là 4."). Trong Lighthouse: security.max_query_depth, thông báo "Max query depth should be 5 but got 6.".

3.2 Query rộng: alias và first: 100 — depth limit không thấy

500 alias → query 25.7 KB, friends resolver chạy 500 lần, 50000 user trả về, 178ms
# { a0: user(id: 1) { friends(first: 100) { name } } a1: user(id: 2) { … } … a499: … }   ← độ sâu chỉ 2

Query chỉ sâu 2 tầng nhưng trả 50 000 object. Chặn: cost analysis — tính điểm từng field, nhân theo tham số list. Dùng graphql-query-complexity 2.0.0 (hỗ trợ graphql 16 và 17):

createComplexityRule({
  maximumComplexity: 1000,
  variables,                                   // phải truyền biến — nếu không, first: $n bị tính như không có
  estimators: [
    // list có tham số first: chi phí = first × (chi phí 1 phần tử)
    ({ args, childComplexity, field }) => (field.name === 'friends' ? (args.first ?? 10) * (1 + childComplexity) : undefined),
    simpleEstimator({ defaultComplexity: 1 }),
  ],
  createError: (max, actual) => new GraphQLError(`Query tốn ${actual} điểm, giới hạn là ${max}.`),
});
friends lồng 2 tầng, first = 10 (hợp lệ)   → {"ok":true,"friendsResolverCalls":11,"bytes":2336}
friends lồng 5 tầng                        → {"errors":["Query sâu 6 tầng, giới hạn là 4.","Query tốn 222222 điểm, giới hạn là 1000."]}
friends lồng 5 tầng, giấu trong fragment   → {"errors":["Query sâu 6 tầng, giới hạn là 4.","Query tốn 211111 điểm, giới hạn là 1000."]}
chỉ lồng 2 tầng nhưng first: 100 × 100     → {"errors":["Query tốn 20202 điểm, giới hạn là 1000."]}
50 alias × friends(first: 100), độ sâu 2   → {"errors":["Query tốn 10050 điểm, giới hạn là 1000."]}
dùng biến $n = 100 thay cho số viết cứng → {"errors":["Query tốn 20101 điểm, giới hạn là 1000."]}

Cost rule bắt được cả ba ca mà depth limit bỏ qua. Nhưng nó chỉ tốt bằng mô hình chi phí bạn đưa vào — xem ngay Lighthouse ở mục 3.5.

3.3 Brute-force bằng alias: rate limit theo request vô dụng

mutation { g0000: login(email: "admin@corp.example", otp: "0000")
           g0001: login(email: "admin@corp.example", otp: "0001")
           …  ×10 000 }

1 HTTP request (rate limiter đếm = 1), body 537.1 KB, thử 10000 mã OTP trong 841ms → mã đúng: 7391
Một HTTP request chứa 10 nghìn lần thử OTP Kẻ tấn công gửi một request duy nhất. Bộ rate limit 5 request mỗi phút đếm được 1 và cho qua. Bên trong server, 10 nghìn field login chạy lần lượt và một field trả true ở mã 7391. kẻ tấn công 1 request · 537 KB rate limit: 5 request/phút đếm: 0 đếm: 1 → cho qua server: 10 000 field login(otp) trong 1 operation g7391: true — tìm ra mã sau 841ms, vẫn là 1 request mỗi ô ≈ 83 lần thử · đỏ = sai
Hình 3 — Rate limit đếm request nên chỉ thấy 1. PortSwigger mô tả đúng kiểu này: "aliases effectively enable you to send multiple queries in a single HTTP message, they can bypass this restriction". Biến thể khác: gửi mảng nhiều operation trong 1 HTTP request (batching) — Apollo Server 5 tắt sẵn ("Operation batching disabled.", lab 06).

Chặn — theo thứ tự hiệu quả:

  1. Đếm số lần thử ở tầng nghiệp vụ, theo tài khoản: resolver login tăng bộ đếm sai trong Redis theo email, khoá sau 5 lần — mỗi alias là một lần thử thật. Đây là chỗ duy nhất đếm đúng.
  2. Giới hạn số alias / số field gốc trong một operation (validation rule; thư viện kiểu graphql-armor có maxAliases — chưa đánh giá trong lab).
  3. Cost analysis gán điểm cao cho mutation nhạy cảm.
  4. Tắt batching mảng JSON nếu không cần.

3.4 DoS ngay ở bước validate — trước khi resolver nào chạy

Phần này ít tài liệu nói tới. Luật OverlappingFieldsCanBeMergedRule (spec bắt buộc: các field cùng tên trong response phải gộp được) phải so các field cùng tên với nhau từng cặp. Cài đặt của graphql-js tốn thời gian tăng nhanh hơn tuyến tính — và với field trùng tên nhưng khác nhau, tốn cả RAM:

# graphql-js 17.0.2 — lab 04b, mỗi ca 1 process riêng, heap mặc định
cùng 1 field    (f0 f0 f0 …)
  n=1000 |   2.9 KB | validate    215ms |   0 lỗi | heap sau validate    9 MB
  n=2000 |   5.9 KB | validate    453ms |   0 lỗi | heap sau validate   11 MB
  n=4000 |  11.7 KB | validate   1548ms |   0 lỗi | heap sau validate   13 MB
trùng tên, khác field (x: f0 x: f1 …)
  n=1000 |   5.9 KB | validate    425ms | 101 lỗi | heap sau validate   89 MB
  n=2000 |  11.7 KB | validate   2726ms | 101 lỗi | heap sau validate  331 MB
  n=4000 |  23.5 KB | validate  11053ms | 101 lỗi | heap sau validate 1235 MB

# lab 04 mục D — 8000 field, process con giới hạn heap 256 MB
"x: f0 x: f1" × 8000, heap 256MB → stdout: body 46.9 KB, bắt đầu validate | sau 4.9s process chết: signal=SIGABRT status=null
  stderr: FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory

# cùng query "f0" lặp, bỏ riêng 1 luật đó ra → thủ phạm đúng là luật này
"f0" lặp 4000 lần (11.7 KB) → validate đủ 32 luật:  1137ms | bỏ riêng OverlappingFieldsCanBeMergedRule: 13.2ms
Thời gian validate của graphql-js so với webonyx/graphql-php Cùng kiểu query trùng tên khác field. graphql-js 17: 1000 field mất 0,43 giây, 2000 field 2,7 giây, 4000 field 11 giây và 1,2 GB RAM, 8000 field làm process chết vì hết heap. webonyx/graphql-php 15: 45ms, 88ms, 189ms và 407ms, RAM dưới 80 MB. Chỉ bước validate — query "x: f0 x: f1 …" (trùng tên, khác field) n = 1 000n = 2 000n = 4 000n = 8 000 graphql-js 0,43s · 89 MB webonyx 45ms · 62 MB graphql-js 2,7s · 331 MB webonyx 88ms · 64 MB 11s · 1,2 GB webonyx 189ms · 68 MB graphql-js: process chết — hết heap webonyx 407ms · 78 MB đỏ: graphql-js 17.0.2 (Node 22) · xanh: webonyx/graphql-php 15.37.2 (nền của Lighthouse) · thang: 1 giây ≈ 34px
Hình 4 — Cùng một luật trong spec, hai cài đặt khác nhau một trời một vực. Với graphql-js, một request 23,5 KB chiếm event loop 11 giây — mọi request khác trên process đó đứng chờ (xem bài chặn event loop). graphql-js 16.14.2 cũng vậy (11,5 giây, 1,27 GB ở n = 4 000 — file out/04b-validate-dos.graphql16.txt), nên đây không phải lỗi riêng của bản 17.

Chặn: giới hạn phải nằm trước validate. Giới hạn kích thước body không đủ (23,5 KB đã đủ chết; nhiều framework mặc định cho phép 100 KB–1 MB). Cách đúng:

parse(…, { maxTokens: 2000 }) → Syntax Error: Document contains more than 2000 tokens. Parsing aborted. (19.4ms)

// Apollo Server 5: truyền xuống graphql-js
new ApolloServer({ schema, parseOptions: { maxTokens: 2000 }, validationRules: [depthLimitRule(8), costRule] });

Và tốt nhất: trusted documents (mục 3.10) — query lạ không bao giờ tới được bước validate.

3.5 Lighthouse (Laravel): mặc định mở hết

// vendor/nuwave/lighthouse/src/lighthouse.php (6.71.0) — giá trị mặc định
'security' => [
    'max_query_complexity' => GraphQL\Validator\Rules\QueryComplexity::DISABLED,
    'max_query_depth' => GraphQL\Validator\Rules\QueryDepth::DISABLED,
    'disable_introspection' => (bool) env('LIGHTHOUSE_SECURITY_DISABLE_INTROSPECTION', false) ? … ENABLED : … DISABLED,
],
'pagination' => [ 'default_count' => null, 'max_count' => null ],   // không giới hạn số phần tử mỗi trang

Query 7 tầng trên quan hệ có vòng (users → posts → author → posts → author → posts → author), 50 user × 10 post:

=== memory_limit=128M (mặc định của image) ===
PHP FATAL (peak 128 MB): Allowed memory size of 134217728 bytes exhausted (tried to allocate 20480 bytes)
→ process thoát với mã 255

=== memory_limit=1G ===
HTTP 200 | 7 câu SQL | 14955ms | response 1580.2 KB | RAM đỉnh 764 MB

Batching vẫn chạy đúng (chỉ 7 câu SQL) — vấn đề không phải N+1 mà là số object Eloquent. Với php-fpm, một request như vậy giữ một worker 15 giây; vài request song song là hết pm.max_children (xem bài scale nginx + php-fpm).

Bật giới hạn — và một bẫy quan trọng của max_query_complexity:

// security: max_query_depth = 5, max_query_complexity = 100, disable_introspection = ENABLED
query 7 tầng field          → LỖI: Max query depth should be 5 but got 6. | complexity tính ra = 8
users→posts→comments→author → chạy, 4 câu SQL, 94976 byte | complexity tính ra = 8
postsPaged(first: 100000)   → chạy, 3 câu SQL, 11712 byte | complexity tính ra = 4
postsPaged(first: 1)        → chạy, 3 câu SQL, 2359 byte | complexity tính ra = 4

Query trả 2 050 object được tính 8 điểm; first: 100000 và first: 1 cùng 4 điểm. Đọc source 6.71.0: chỉ @paginate và @complexity cài ComplexityResolverDirective (nhân điểm theo first); các directive quan hệ như @hasMany, @all không cài, nên mỗi field chỉ là "1 + điểm của con" — list không giới hạn bị tính như một object. max_query_complexity khi đó chỉ là giới hạn số field, không phải chi phí. Chữa:

'pagination' => ['default_count' => 20, 'max_count' => 100],   // lab: "Maximum number of 50 requested items exceeded, got 100000."
type User {
  posts: [Post!]! @hasMany(type: PAGINATOR) @complexity(resolver: "App\\GraphQL\\Complexity@byFirst")
}
// và không để list không phân trang (@all, @hasMany thường) trên quan hệ có thể lớn

Một chi tiết về schema cache: đặt max_count trong file config (đọc lúc boot) thì có tác dụng ngay cả khi file schema cache đã có (lab D2); đổi bằng config() lúc đang chạy thì không — schema trong process đã dựng xong.

3.6 Introspection tắt rồi, schema vẫn lộ qua "Did you mean"

introspection → GraphQL introspection has been disabled, but the requested query contained the field "__schema".
đoán { usr }                  → Cannot query field "usr" on type "Query". Did you mean "user"?
đoán { user(id: 1) { emai } } → Cannot query field "emai" on type "User". Did you mean "email"?
đoán mutation { logn }        → Cannot query field "logn" on type "Mutation". Did you mean "login"?
với { hideSuggestions: true }         → Cannot query field "usr" on type "Query".

Công cụ như Clairvoyance tự động đoán tên và dựng lại gần đủ schema từ các gợi ý này (PortSwigger). Ba stack, ba cách:

StackTắt introspectionTắt gợi ý "Did you mean"Đo trong lab
graphql-js 17NoSchemaIntrospectionCustomRulevalidate(…, { hideSuggestions: true })Có (lab 04-F)
Apollo Server 5introspection: false (mặc định khi NODE_ENV=production)hideSchemaDetailsFromClientErrors: true (mặc định false)Có (lab 06-E, cả production)
Lighthouse 6.71 / webonyx 15.37security.disable_introspectionKhông có tuỳ chọn — chuỗi ghép cứng trong FieldsOnCorrectType; gợi ý vẫn hiện khi APP_DEBUG=falseCó (lab 08-E, G)

Với Lighthouse, cách khả thi là một error handler (lighthouse.error_handlers) tự cắt phần "Did you mean …" khỏi lỗi validate — chưa dựng lab. Và nhớ: tắt introspection là giảm thông tin, không phải ranh giới bảo mật. Schema nên được coi là công khai; bảo vệ thật nằm ở phân quyền và giới hạn chi phí.

3.7 CSRF: Lighthouse mặc định chạy mutation từ GET và form HTML

CSRF xảy ra khi API dùng cookie để xác thực (session, Sanctum SPA) và server chấp nhận những request mà trình duyệt gửi được "không cần hỏi" (không preflight): GET, hoặc POST với application/x-www-form-urlencoded, multipart/form-data, text/plain.

# Lighthouse 6.71.0, cấu hình mặc định (middleware EnsureXHR đang bị comment trong config)
GET query                        → HTTP 200 | {"data":{"users":[…]}}
GET mutation                     → HTTP 200 | {"data":{"touch":1}}
POST form-urlencoded mutation    → HTTP 200 | {"data":{"touch":2}}
POST text/plain mutation         → HTTP 400 | "Could not decode request with content type: \"text/plain\"."
số dòng tmp_lh_writes (mutation đã thật sự ghi): 2

# bật Nuwave\Lighthouse\Http\Middleware\EnsureXHR
GET mutation                     → HTTP 400 | "GET requests are forbidden"
POST form-urlencoded mutation    → HTTP 400 | "Content-Type application/x-www-form-urlencoded is forbidden"
POST text/plain mutation         → HTTP 400 | "Content-Type text/plain is forbidden"
POST application/json mutation   → HTTP 200 | {"data":{"touch":1}}

# Apollo Server 5.5.1, mặc định (csrfPrevention: true)
GET ?query={ok} (như thẻ <img>/<form>)  → HTTP 400 | "This operation has been blocked as a potential Cross-Site Request Forgery (CSRF). …"
POST text/plain (form HTML gửi được)    → HTTP 400 | (cùng thông báo CSRF)
mutation qua GET                        → HTTP 405 | "GET requests only support query operations, not mutation operations"
sequenceDiagram
  participant V as Trình duyệt nạn nhân, đã đăng nhập
  participant E as Trang lạ evil.example
  participant L as Lighthouse mặc định
  V->>E: mở trang
  E-->>V: form ẩn tự submit, form-urlencoded
  V->>L: POST query=mutation kèm cookie phiên
  L-->>V: 200 data touch 2, đã ghi thật
  Note over L: bật EnsureXHR thì GET và form-urlencoded trả 400

Nếu token đi trong header Authorization (không phải cookie), trình duyệt không tự gắn nên CSRF không áp dụng. Nhưng chỉ cần một màn hình dùng session cookie (SPA của Sanctum) là phải bật EnsureXHR. Đây là một dòng trong config — và mặc định nó đang tắt.

3.8 Phân quyền theo field: cùng một field, nhiều đường đi

flowchart LR
  Q["Query"] --> ME["me: có kiểm tra đăng nhập"]
  Q --> P["post(id)"]
  Q --> U["user(id)"]
  ME --> E1["User.email"]
  P --> A["author"] --> E2["User.email"]
  U --> F["friends"] --> E3["User.email"]
1) chỉ kiểm tra ở Query.me, email: String!
  khách chưa login gọi { me { email } }  → "Cần đăng nhập"
  khách chưa login đi đường post/friends → {"post":{"title":"post của user 1","author":{"email":"u1@corp.example"}},
                                           "user":{"friends":[{"name":"user 15","email":"u15@corp.example"}, …]}}
2) kiểm tra ngay ở User.email, email: String! (non-null)
  khách chưa login đi đường post/friends → {"post":null,"user":null}
  errors: ["post.author.email: Cannot return null for non-nullable field User.email.", …]
3) kiểm tra ngay ở User.email, email: String (nullable)
  khách chưa login đi đường post/friends → {"post":{"title":"post của user 1","author":{"email":null}},
                                           "user":{"friends":[{"name":"user 15","email":null}, …]}}

Ba bài học trong một lab: (1) kiểm tra ở "cửa vào" là hở; (2) kiểm tra ở field nhưng field non-null thì null lan truyền xoá luôn post và user — trải nghiệm hỏng và log đầy lỗi giả; (3) field nhạy cảm nên nullable + kiểm tra ở chính field (hoặc tầng domain). Trong Laravel: directive @can/@guard của Lighthouse hoặc Policy gọi trong service; quyền theo bản ghi (ai được xem email của ai) là ABAC — xem bài ABAC và RBAC. OWASP còn nhắc một đường hay quên: field node(id: ID!) kiểu Relay cho phép lấy bất kỳ object nào theo global id → IDOR nếu không kiểm tra quyền trên từng object.

3.9 Message lỗi: framework nào che, framework nào không

# Apollo Server 5.5.1, NODE_ENV=production — stacktrace mất, message thì còn nguyên
resolver ném lỗi "{ boom }" → HTTP 200 | {"errors":[{"message":"SQLSTATE[42P01]: relation \"users_v2\" does not exist; host=10.0.3.17", …,
                                         "extensions":{"code":"INTERNAL_SERVER_ERROR"}}],"data":{"boom":null}}
# Lighthouse 6.71.0
APP_DEBUG=true  → message: Internal server error | debugMessage: SQLSTATE[42P01]: … host=10.0.3.17 user=app_rw | trace: 56 frame
APP_DEBUG=false → message: Internal server error | debugMessage: - | trace: không

Apollo cần formatError để che: giữ message cho lỗi có mã nghiệp vụ (BAD_USER_INPUT, FORBIDDEN...), thay mọi lỗi khác bằng thông báo chung + request id để tra log.

3.10 Trusted documents: lớp mạnh nhất cho app của chính mình

Ý tưởng: lúc build app (web, mobile), công cụ trích mọi query ra, tính hash, đăng ký danh sách lên server. Lúc chạy, client chỉ gửi hash; server chỉ chạy những hash có trong danh sách. Query lạ — kể cả tất cả các kiểu tấn công ở trên — bị từ chối trước cả parse.

Đừng nhầm với APQ. APQ cũng gửi hash, nhưng khi server chưa có hash thì client gửi kèm cả query để đăng ký — tức là ai cũng đăng ký được query bất kỳ:

# Apollo Server 5.5.1, APQ mặc định
APQ đăng ký query lạ bất kỳ (không phải allowlist) → HTTP 200 | {"data":{"a":"ok","b":"ok","c":"ok"}}

APQ là tối ưu băng thông và cache (bài 5), không phải bảo mật. Trusted documents chỉ dùng được khi bạn kiểm soát mọi client; API công khai cho đối tác thì phải dựa vào cost analysis + rate limit theo chi phí.

4. Đảm bảo và giới hạn

5. Failure modes

Tấn công / sự cốLabHệ quả đo đượcChặn ở
Query sâu trên đồ thị vòng04-A109 ký tự → 2,2 MB, 100 000 objectDepth + cost
Query sâu trên Lighthouse mặc định08-D1PHP chết (128M); 1G → 15s, 764 MBmax_query_depth, list có phân trang
Alias / first lớn04-B, E50 000 object ở độ sâu 2Cost
Brute-force OTP bằng alias04-C10 000 lần thử / 1 request, tìm ra mãĐếm lần thử theo tài khoản + giới hạn alias
DoS ở validate04-D, 04b23,5 KB → 11s + 1,2 GB; 47 KB → process chếtmaxTokens, trusted documents
Lộ schema qua gợi ý04-F, 06-E, 08-E"Did you mean "email"?"hideSuggestions / Apollo option; Lighthouse: tự lọc
CSRF08-H/I, 06-CMutation ghi thật từ GET và formEnsureXHR; Apollo csrfPrevention
Phân quyền ở cửa vào04-GLộ email qua post.author, friendsKiểm tra ở field/domain, field nullable
Lộ message lỗi06-A (production), 08-GLộ bảng, IP, user DBformatError, APP_DEBUG=false

6. Vận hành trong production

Cấu hình khởi đầu đề xuất (số là điểm xuất phát, phải đo trên query thật của bạn rồi chỉnh):

// Node — Apollo Server 5
new ApolloServer({
  schema,
  parseOptions: { maxTokens: 5000 },          // chặn trước validate (mục 3.4)
  validationRules: [depthLimitRule(10), costRule],
  hideSchemaDetailsFromClientErrors: true,    // bỏ "Did you mean"
  includeStacktraceInErrorResponses: false,
  formatError: maskInternalErrors,            // giữ lỗi nghiệp vụ, che phần còn lại
  // csrfPrevention: true, allowBatchedHttpRequests: false — đã là mặc định, đừng tắt
});

// Laravel — config/lighthouse.php
'route' => ['middleware' => [EnsureXHR::class, AcceptJson::class, AttemptAuthentication::class]],
'security' => [
    'max_query_depth' => 10,
    'max_query_complexity' => 1000,           // chỉ có ý nghĩa khi list có @paginate/@complexity
    'disable_introspection' => DisableIntrospection::ENABLED,
],
'pagination' => ['default_count' => 20, 'max_count' => 100],
// APP_DEBUG=false ở production; memory_limit + request_terminate_timeout của php-fpm là lưới an toàn cuối

7. So sánh và ma trận quyết định

Biện phápChặn đượcBỏ lọtChi phí vận hành
Giới hạn bodyRequest khổng lồGần như mọi thứ trong bài (12–47 KB là đủ)Rất thấp
maxTokensDoS parse/validate, query khổng lồQuery ngắn mà đắt (sâu, first lớn)Thấp
Depth limitLồng sâuAlias, first lớn, brute-forceThấp
Cost analysisSâu + rộng + alias (nếu mô hình đúng)List không có tham số giới hạn; mô hình saiTrung bình: phải duy trì mô hình
Giới hạn alias / field gốcBrute-force, khuếch đạiQuery sâuThấp
Đếm lần thử ở nghiệp vụBrute-force mọi dạngDoSThấp
Trusted documentsGần như tất cả với first-partyKhông dùng được cho API công khaiTrung bình: pipeline build + registry
Phân quyền ở field/domainLộ dữ liệu qua đường khác, IDORDoSTrung bình

App first-party (web/mobile của chính công ty): trusted documents + phân quyền ở domain + che lỗi; các giới hạn khác là lưới dự phòng. API công khai / đối tác: maxTokens + depth + cost (mô hình đúng) + rate limit theo chi phí + bắt buộc first/last có trần, như GitHub.

8. Góc nhìn tech lead: tác động tổ chức

9. Anti-patterns và bài học thực tế

  1. "Đã có depth limit là đủ" — alias và first lớn đi xuyên qua (mục 3.2).
  2. Tin vào complexity mặc định — Lighthouse tính list không giới hạn là 1 điểm (mục 3.5). GitLab có hai CVE năm 2025 đúng loại "vượt qua giới hạn complexity".
  3. Rate limit theo request cho endpoint đăng nhập/OTP (mục 3.3).
  4. Coi tắt introspection là bảo mật (mục 3.6).
  5. Coi APQ là allowlist (mục 3.10).
  6. Kiểm tra quyền ở resolver gốc rồi tin mọi field con (mục 3.8).
  7. Giữ cấu hình mặc định của framework (CSRF, debug, giới hạn).

Nguồn công khai đáng đọc: OWASP GraphQL Cheat Sheet (giới hạn độ sâu và số lượng, timeout, cost analysis, kiểm tra quyền "on both edges and nodes", tắt introspection và GraphiQL ở production); PortSwigger Web Security Academy — GraphQL API vulnerabilities (có lab thực hành alias brute-force, CSRF qua GraphQL, khôi phục schema từ gợi ý); GitHub Docs — Rate limits and query limits for the GraphQL API (5 000 điểm/giờ, 500 000 node/lần gọi, first/last trong 1–100, cắt request quá 10 giây, tối đa 2 000 điểm/phút); GitLab 18.6.2 patch release (CVE-2025-12562).

10. Câu hỏi phụ interviewer hay hỏi tiếp

Depth limit hay cost analysis?
Cả hai, nhưng cost analysis là chính: depth không thấy độ rộng. Lab: first: 100 × 100 sâu 2 tầng vẫn 20 202 điểm.
Rate limit GraphQL thế nào?
Theo chi phí (điểm) chứ không theo request; với thao tác nhạy cảm, đếm lần thử ở tầng nghiệp vụ theo tài khoản.
Có nên tắt introspection ở production?
Với API nội bộ thì có, nhưng nó không phải ranh giới bảo mật — gợi ý field vẫn lộ schema; phải tắt cả gợi ý và coi schema là công khai.
Persisted query có chống được tấn công không?
Trusted documents (allowlist) thì có; APQ thì không — ai cũng đăng ký được query mới.
GraphQL có bị CSRF không?
Có, nếu xác thực bằng cookie và server nhận GET hoặc form-urlencoded. Lighthouse mặc định nhận cả hai cho mutation; bật EnsureXHR.
Vì sao một query 23 KB làm server Node treo 11 giây?
Luật gộp field trùng tên trong bước validate của graphql-js tăng nhanh hơn tuyến tính; chặn bằng maxTokens trước validate.
Phân quyền đặt ở resolver hay ở đâu?
Ở tầng domain/service mà mọi đường đều phải đi qua, hoặc ít nhất ở chính field nhạy cảm; field đó nên nullable.

Phụ lục: lab đã chạy

Môi trường: graphql-js 17.0.2 (16.14.2 để so sánh), graphql-query-complexity 2.0.0, Node 22.23.3; Apollo Server 5.5.1 (graphql 16.14.2); Laravel 13.34.0 + Lighthouse 6.71.0 + webonyx/graphql-php 15.37.2 + PHP 8.3.33 (memory_limit mặc định 128M); PostgreSQL 18.4. Máy 4 vCPU dùng chung — thời gian chỉ xem tỉ lệ. Output: labs/graphql/out/04-security.txt, 04b-validate-dos.txt, 04b-validate-dos.graphql16.txt, 06-apollo-http*.txt, 08-lighthouse.txt.

cd labs/graphql/node
./run.sh 04-security.mjs          # A–G: sâu, alias, brute-force, DoS validate, luật chặn, gợi ý, phân quyền
./run.sh 04b-validate-dos.mjs     # bảng chi phí validate theo n

cd ../federation
./run.sh 06-apollo-http.mjs       # mã HTTP, CSRF, batching, introspection, APQ
docker run --rm -e NODE_ENV=production -v "$PWD":/lab -w /lab node:22-bookworm-slim node 06-apollo-http.mjs

cd ../laravel
./setup.sh /tmp/lh-app            # kịch bản deep, maxcount, security, errors, http, http-xhr

Chưa verify: thư viện graphql-armor (maxAliases, maxTokens cho Yoga/Apollo); error handler lọc gợi ý cho Lighthouse; các công cụ graphql-cop, InQL, Clairvoyance; cài đặt validate của graphql-java/.NET với cùng query độc hại.