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__schematrả 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ớihideSuggestions,maxErrors;parse()vớimaxTokens), 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à webonyxFieldsOnCorrectType. 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ỡ:
- "Một request = một đơn vị chi phí" — sai: một request có thể rẻ như 1 câu SQL hoặc đắt như hàng trăm nghìn object.
- "Một request = một lần thử" — sai: alias nhét hàng nghìn lần gọi mutation vào một request.
- "Kiểm tra quyền ở endpoint là đủ" — sai: không còn endpoint, chỉ còn các đường đi trong đồ thị.
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.
- GraphQL tối ưu cho: sự linh hoạt của client.
- Không tối ưu cho: chi phí có giới hạn và đoán trước được — phần này là việc của bạn, framework mặc định hầu như không làm (Lighthouse mặc định tắt cả giới hạn độ sâu lẫn chi phí — lab ở mục 3.5).
2. Mô hình tư duy cốt lõi
- Đị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.
- 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.
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
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
"Operation batching disabled.", lab 06).Chặn — theo thứ tự hiệu quả:
- Đếm số lần thử ở tầng nghiệp vụ, theo tài khoản: resolver
logintă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. - 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). - Cost analysis gán điểm cao cho mutation nhạy cảm.
- 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
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:
| Stack | Tắt introspection | Tắt gợi ý "Did you mean" | Đo trong lab |
|---|---|---|---|
| graphql-js 17 | NoSchemaIntrospectionCustomRule | validate(…, { hideSuggestions: true }) | Có (lab 04-F) |
| Apollo Server 5 | introspection: 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.37 | security.disable_introspection | Không có tuỳ chọn — chuỗi ghép cứng trong FieldsOnCorrectType; gợi ý vẫn hiện khi APP_DEBUG=false | Có (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
- Depth limit đảm bảo độ sâu, không đảm bảo độ rộng (alias,
first). - Cost analysis chỉ là ước lượng tĩnh: không biết kích thước list thật nếu không có tham số giới hạn; sai mô hình là thành giới hạn đếm field (Lighthouse mục 3.5); phải tính cả biến.
- maxTokens đảm bảo parse và validate có trần chi phí — nhưng đặt quá thấp sẽ chặn query hợp lệ lớn (form nhiều field). Đo token của các query thật trước khi chọn số.
- Trusted documents đảm bảo gần như tuyệt đối với first-party, không áp dụng được cho API công khai.
- Timeout không dừng I/O đang chạy (bài 2, mục 3.11).
- Tắt introspection không đảm bảo schema bí mật (gợi ý field, bundle JS của client chứa query).
5. Failure modes
| Tấn công / sự cố | Lab | Hệ quả đo được | Chặn ở |
|---|---|---|---|
| Query sâu trên đồ thị vòng | 04-A | 109 ký tự → 2,2 MB, 100 000 object | Depth + cost |
| Query sâu trên Lighthouse mặc định | 08-D1 | PHP chết (128M); 1G → 15s, 764 MB | max_query_depth, list có phân trang |
Alias / first lớn | 04-B, E | 50 000 object ở độ sâu 2 | Cost |
| Brute-force OTP bằng alias | 04-C | 10 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 ở validate | 04-D, 04b | 23,5 KB → 11s + 1,2 GB; 47 KB → process chết | maxTokens, trusted documents |
| Lộ schema qua gợi ý | 04-F, 06-E, 08-E | "Did you mean "email"?" | hideSuggestions / Apollo option; Lighthouse: tự lọc |
| CSRF | 08-H/I, 06-C | Mutation ghi thật từ GET và form | EnsureXHR; Apollo csrfPrevention |
| Phân quyền ở cửa vào | 04-G | Lộ email qua post.author, friends | Kiểm tra ở field/domain, field nullable |
| Lộ message lỗi | 06-A (production), 08-G | Lộ bảng, IP, user DB | formatError, 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
- Metric: số query bị từ chối theo lý do (độ sâu, chi phí, token) và theo client — tăng đột ngột là dấu hiệu dò quét; phân phối điểm chi phí của query hợp lệ (để chỉnh ngưỡng); thời gian validate (nếu tăng là có ai đang thử mục 3.4).
- WAF truyền thống gần như mù với GraphQL (mọi thứ là
POST /graphql). Nếu có gateway, cho nó hiểu GraphQL (đếm alias, độ sâu, token) hoặc làm ở app. - Rate limit theo chi phí cho API công khai: mỗi client có ngân sách điểm/giờ, mỗi query trừ đúng số điểm của nó (mô hình của GitHub).
- Kiểm thử bảo mật: đưa các ca ở mục 5 thành test tự động; công cụ dò quét chuyên cho GraphQL như graphql-cop, InQL, Clairvoyance (chưa đánh giá trong lab này).
7. So sánh và ma trận quyết định
| Biện pháp | Chặn được | Bỏ lọt | Chi phí vận hành |
|---|---|---|---|
| Giới hạn body | Request khổng lồ | Gần như mọi thứ trong bài (12–47 KB là đủ) | Rất thấp |
maxTokens | DoS parse/validate, query khổng lồ | Query ngắn mà đắt (sâu, first lớn) | Thấp |
| Depth limit | Lồng sâu | Alias, first lớn, brute-force | Thấp |
| Cost analysis | Sâu + rộng + alias (nếu mô hình đúng) | List không có tham số giới hạn; mô hình sai | Trung bình: phải duy trì mô hình |
| Giới hạn alias / field gốc | Brute-force, khuếch đại | Query sâu | Thấp |
| Đếm lần thử ở nghiệp vụ | Brute-force mọi dạng | DoS | Thấp |
| Trusted documents | Gần như tất cả với first-party | Không dùng được cho API công khai | Trung bình: pipeline build + registry |
| Phân quyền ở field/domain | Lộ dữ liệu qua đường khác, IDOR | DoS | Trung 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
- Viết chính sách vào ADR: "API GraphQL nội bộ chỉ chạy trusted documents"; "mọi list trả về phải có phân trang với trần"; "field nhạy cảm (email, số điện thoại, số dư) phải nullable và kiểm tra quyền ở chính field".
- Checklist review schema cho mọi PR đổi schema: có list không giới hạn không? có field nhạy cảm non-null không? có mutation đăng nhập/OTP/mã giảm giá cần đếm lần thử không? có quan hệ tạo vòng không?
- Mặc định của framework không đáng tin: Lighthouse tắt mọi giới hạn và tắt chống CSRF; Apollo để message lỗi đi ra ngoài. Dự án mới nên có một "security baseline" cấu hình sẵn và test chứng minh nó còn hiệu lực.
- Pentest: đưa GraphQL vào phạm vi riêng, đừng để người kiểm thử chỉ thử "endpoint"
/graphqlnhư một URL REST.
9. Anti-patterns và bài học thực tế
- "Đã có depth limit là đủ" — alias và
firstlớn đi xuyên qua (mục 3.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".
- Rate limit theo request cho endpoint đăng nhập/OTP (mục 3.3).
- Coi tắt introspection là bảo mật (mục 3.6).
- Coi APQ là allowlist (mục 3.10).
- Kiểm tra quyền ở resolver gốc rồi tin mọi field con (mục 3.8).
- 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 × 100sâ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
maxTokenstrướ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.