Dẫn nhập

GraphQL là công nghệ giúp client truy vấn API một cách linh hoạt hơn, nó cũng khắc phục được những vấn đề đang có của Restful API nên nhanh chóng trở thành lựa chọn phổ biến để xây dựng API của nhiều developers hiện nay. Nhưng giống với những công nghệ mới, GraphQL đi kèm với những rủi ro về bảo mật. Trong bài này chúng ta sẽ cùng tìm hiểu và giải quyết chúng.

Bài viết giả định rằng các bạn đã có kiến thức về GraphQL, tuy nhiên nếu gặp khó khăn các bạn có thể đặt câu hỏi ở phần bình luận để mình hỗ trợ nha 😘😘

Bảo kê 01: Giới hạn chiều sâu của truy vấn (Depth limiting)

Như đã nói ở trên, GraphQL cho phép client yêu cầu bất kỳ thông tin nào từ API, việc này dẫn đến trường hợp client có thể truy vấn lồng nhau tạo thành vòng lặp. Ví dụ mình có bảng course có thể lấy ra danh sách các categories và ngược lại từ categoires cũng có thể lấy ra danh sách khóa học có cùng category. Truy vấn có thể được client thực hiện như này:

query TuiTruyVan {       # Depth: 0
  author(id: "abc") {    # Depth: 1
    posts {              # Depth: 2
      author {           # Depth: 3
        posts {          # Depth: 4
          author {       # Depth: 5
            posts {      # Depth: 6
              author {   # Depth: 7
                # Sâu, sâu nữa, sâu mãi, sâu bao nhiêu cũng được 😆😆
              }
            }
          }
        }
      }
    }
  }
}

Việc này dẫn đến hao phí rất nhiều tài nguyên và có thể dẫn để bị treo hệ thống đó 😨😨

Trong trường hợp này thì cách tốt nhất để bảo vệ API của bạn là giới hạn độ sâu của truy vấn để những truy vấn có kích thước lớn sẽ bị chặn trước khi được thực thi.

GraphQL Depth Limit là package mình đề xuất các bạn sử dụng để giải quyết lỗ hỗng này vì nó hiệu quả và rất-dễ-dùng 🤣🤣

import depthLimit from 'graphql-depth-limit' // <-- Import vào nè
import express from 'express'
import graphqlHTTP from 'express-graphql'
...

const app = express()

app.use('/graphql', graphqlHTTP((req, res) => ({
  ...,
  validationRules: [ depthLimit(5) ], // <-- Mình giới hạn độ sâu tối đa là 5 thôi nha
})))

Ví dụ trên mình giới hạn độ sâu truy vấn là 5, tức là bạn chỉ có thể truy vấn đến Depth 5 thôi nha. Quy định độ sâu bao nhiêu thì tùy vào yêu cầu dự án, nhưng những dự án mình làm không yêu cầu về việc này thì mình thường đặt độ sâu tối đa trong khoảng 3 đến 5.

Bảo kê 02: Tính toán độ phức tạp của truy vấn (Query Complexity) và giới hạn chúng

Như đã nói ở trên, không chỉ những truy vấn có số lượng và độ sâu lớn thì mới dẫn đến hao phí tài nguyên, những truy vấn đơn giản cũng có thể dẫn đến hậu quả tương tự vì độ phức tạp lớn cần nhiều tài nguyên để xử lý.

Để các bạn dễ tiếp cận sau này, mình sẽ dùng từ query complexity thay cho độ phức tạp của truy vấn nha 😇😇

Query complexity cho phép bạn xác định độ phức tạp của các trường trong truy vấn và để hạn chế các truy vấn có độ phức tạp lớn. Để làm việc này, chúng ta xác định độ phức tạp của từng trường (field) bằng cách sử dụng số đơn giản. Thông thường mỗi trường sẽ có độ phức tạp là 1. Chúng ta hãy xem ví dụ dưới đây:

query {
  author(id: "abc") {     # complexity: 1
    posts {               # complexity: 1
      title               # complexity: 1
    }
  }
}

Có thể thấy độ phức tạp của truy vấn này là 3, nếu đặt độ phức tạp tối đa là 2 thì truy vấn này sẽ thất bại.

Trong trường hợp trường posts phức tạp hơn trường author, chúng ta có thể đặt độ phức tạp khác nhau ở mỗi trường. Chúng ta còn có thể thiết lập độ phức tạp khác nhau dựa trên đối số đầu vào. Hãy xem ví dụ tương tự, nhưng trường posts sẽ có độ phức tạp thay đổi theo đối số đầu vào.

query {
  author(id: "abc") {    # complexity: 1
    posts(first: 5) {    # complexity: 5 <-- 5 lận nha
      title              # complexity: 1
    }
  }
}

Ở ví dụ trên nếu độ phức tập tối đa chúng ta đặt là 7 thì truy vấn sẽ thực hiện thành công.

Để triển khai vào trong GraphQL API của bạn thì cách tốt nhất là sử dụng package graphql-cost-analysis.

Bước 1: Xác định độ phức tạp (hoặc chi phí) tối đa mà server bạn cho phép

app.use(
  '/graphql',
  graphqlExpress(req => {
    return {
      ...,
      validationRules: [
        costAnalysis({
          variables: req.body.variables,
          maximumCost: 1000, // <-- 1k luôn
        }),
      ],
    }
  })
)

Bước 2: Xác định chi phí cho mỗi truy vấn

Query: {
    Article: {
        multipliers: ['limit'],
        useMultipliers: true,
        complexity: 3,
    },
},

Bảo kê 03: Validate tham số truy vấn trong query

Hãy tưởng tượng bạn cho phép người dùng lấy danh sách các authors, tuy nhiên không giới hạn số lượng authors được lấy về sẽ dẫn đến việc phải server phải truy vấn toàn bộ các record trong bảng authors đó. Hậu quả là tài nguyên bị lãng phí và trong trường hợp dữ liệu quá lớn thì... 🤔🤔

Các bạn có thể xem ví dụ dưới đây để dễ hình dung nha:

query {
  authors(min: 0, max: 99999) { # Fetch 99999 authors được nè
    posts {
      title
    }
  }
}

Để khắc phục trường hợp này các bạn có thể kiểm tra ở phía server ha, nếu người dùng không nhập thì chúng ta lấy mặc định, còn nhập quá mức chúng ta cho phép thì trả về lỗi chớ không thèm xử lý 😝😝

Response của mình khi client truy vấn quá lố nè:

{
  "errors": [
    {
      "code": "BAD_USER_INPUT",
      "success": false,
      "message": "\"max\" must be between 1 and 100",
      "errors": [
        {
          "param": "authors"
        }
      ]
    }
  ]
}

Bạn nào muốn trực quan hơn thì có thể sử dụng graphql-input-number để client thấy luôn nha. Cách dùng cực đơn giản:

// Bước 1:
const PaginationAmount = GraphQLInputInt({
  name: 'PaginationAmount',
  min: 1,
  max: 100,
});

// Bước 2:
type Thread {
  messages(first: PaginationAmount, after: String): [Message]
}

Truy vấn sẽ báo lỗi nếu anh em truy vấn nhiều hơn 100 objects.

Bảo kê 04: Validate dữ liệu truyền vào mutation

Việc validate dữ liệu trong mutation là siêu siêu siêu quan trọng nhằm giữ cho dữ liệu truyền lên đúng với dữ liệu được lưu trữ. Đây là bước mà 100% các bạn phải làm trong bất kì server nào nha.

Có 2 package mà mình đề xuất cho các bạn để làm việc này là graphql-shield, ông này validate mọi thứ trên đời luôn, là một package bạn nên thử đó 😚😚. Và graphql-validation, ông này là sản phẩm mình tự đẻ, ổng chỉ tập trung vào validate tham số truyền vào mutation; Ưu điểm là nhỏ, nhẹ, dễ dùng; Nhược điểm là chưa validate được object linh hoạt vì base mình dùng là validator.js. Mình đang cập nhật version 3 với base là yup siêu xịn, các bạn chờ xíu nha 🤗🤗

Bảo kê 05: Rate limiting

Các bạn đã nghe cụm từ "Brute forcing login forms" chưa nhỉ? Đây là một cách tấn công bằng việc cố gắng đăng nhập liên tục vào hệ thống để mò ra thông tin người dùng. Hậu quả là trong 10 năm qua đã có 772,904,991 email cá nhân và 21,222,975  mật khẩu bị tiết lộ (theo báo cáo của Troy Hunt - chuyên gia bảo mật của Microsoft).

Để phòng chống lỗ hổng này, thông thường chúng ta hay dùng Gu gồ cáp cha (reCAPTCHA v3) để ngăn chặn bọn "I'm a robot" theo nghĩa đen 😂😂 

Đối với GraphQL API chúng ta có thể làm cách đơn giản hơn bằng việc sử dụng package graphql-rate-limit cho những API "nhạy cảm". Package này cho phép chúng ta đặt thời gian mà số lượng yêu cầu có thể thực hiện được (time window) và số lần tối đa (max) cho Queries và Muations. Ví dụ: tối đa 5 yêu cầu (max) trong 10s (time window). Điều này sẽ giúp bạn vẫn duy trì trả nghiệm tốt với người dùng I'm not a robot và gây trải nghiệm tệ với bọn I'm a robot 😈😈

Bảo kê 06: 403 forbidden everywhere 😂😂

Một trong những lỗ hổng phổ biển nhất mà các bạn lập trình viên chưa nhiều kinh nghiệm vẫn gặp đó là không kiểm tra và giới hạn quyền truy cập tài nguyên của mỗi người dùng. Ví dụ: Ông A login thành công và có thể cập nhật thông tin của mình, nhưng nếu ông A biết được id của ông B thì ông A cũng có thể cập nhật thông tin mới cho ông B.

function updateUser({ id, email }) { // 👈 Ông A truyền id ông B lên là có thể update được cho cả ông B
  return User.findOneAndUpdate({ _id: id }, { email })
  .catch(error => {
    throw error;
  });
}

Giải pháp là bạn phải kiểm tra id của user từ context của GraphQL chứ không nên lấy id được user truyền lên. Lại ví dụ nè:

function updateUser({ email }, context) {
  return User.findOneAndUpdate({ _id: context.user._id }, { email }) // 👈 user._id được lấy từ trong context đó nha
  .catch(error => {
    throw error; // 👈  vả 403 vô mặt bọn chưa login 🤣🤣
  });
}

Nguồn tham khảo: