Z-SOFT
KỸ THUẬT · BẢO MẬT

Bảo mật SaaS multi-tenant: dữ liệu thường rò rỉ ở đâu

Rò rỉ dữ liệu trong SaaS multi-tenant hiếm khi bắt đầu từ một cuộc tấn công tinh vi. Nó thường đến từ một câu query thiếu tenantId, một route bị cache sai, hoặc một nút đã ẩn ở frontend nhưng API phía sau vẫn mở. Bài viết này rút ra từ những gì chúng tôi đã làm, và từng suýt làm sai, trong Z-EDU, Z-Auto và Z-Cloud Workspace.

Đội kỹ thuật Z-SOFT 10 phút đọc

Khi nói tới bảo mật SaaS, người ta thường hình dung một kẻ tấn công đang dò lỗ hổng. Nhưng trong các hệ thống multi-tenant mà chúng tôi vận hành, dữ liệu suýt rò rỉ thường đến từ những việc rất bình thường: một lập trình viên viết đúng nghiệp vụ nhưng quên điều kiện WHERE tenantId, một route được bật cache "cho nhanh", hoặc một kiểm tra quyền chỉ nằm ở trình duyệt. Không có kỹ thuật tấn công phức tạp nào cả. Vấn đề là mặc định của hệ thống chưa đủ an toàn.

Bài viết này đi qua những điểm dữ liệu multi-tenant dễ rò rỉ nhất: phân quyền, cache, upload, đồng hồ client, rate-limit và danh tính. Mỗi điểm đều có đánh đổi. Chúng tôi sẽ nói rõ các đánh đổi đó thay vì hứa hẹn "an toàn tuyệt đối", vì thứ đó không tồn tại trong vận hành thực tế.

1. Nhiều doanh nghiệp: mọi kiểm soát phải nằm ở server

Z-Auto là nền tảng quản lý showroom ô tô. Mỗi showroom là một tenant, với kho xe, khách hàng CRM, hợp đồng đặt cọc và lịch lái thử riêng. Z-Cloud Workspace cũng là hệ thống multi-tenant, gồm 11 ứng dụng dùng chung một tầng danh tính. Trong cả hai hệ thống, chỉ cần một câu truy vấn thiếu điều kiện tenant là dữ liệu của showroom A có thể xuất hiện trên màn hình của showroom B.

Ba nguồn rò rỉ phổ biến nhất mà chúng tôi thường gặp là:

Trình duyệt là môi trường của người dùng, không phải của bạn. Mọi thứ chạy ở đó — kiểm tra quyền, kiểm tra kiểu file, đếm ngược thời gian — đều là gợi ý về trải nghiệm, không phải kiểm soát bảo mật. Kiểm soát chỉ tồn tại khi nó chạy trên máy bạn kiểm soát.

Nguyên tắc mà chúng tôi lặp lại trong mọi code review: nếu một quyết định ảnh hưởng tới tiền, điểm số, quyền truy cập hay dữ liệu của tenant khác, quyết định đó phải được tính lại ở server, bất kể client đã tính rồi hay chưa.

2. Phân quyền: một nguồn sự thật duy nhất

Trong Z-EDU, toàn bộ danh sách quyền nằm ở packages/config/src/permissions.ts: một file dùng chung cho cả backend NestJS và frontend Next.js trong monorepo pnpm. Không có hằng số quyền nào được khai báo rải rác ở nơi khác. Lý do rất thực dụng: khi quyền được định nghĩa ở hai chỗ, sớm muộn chúng cũng lệch nhau. Và khi lệch, lỗi thường nghiêng về phía mở rộng quyền chứ không phải thu hẹp.

Backend chặn bằng decorator @RequirePermission@PermissionScope. Guard đọc metadata, đối chiếu với quyền thực tế của người dùng lấy từ JWT/DB, và quan trọng nhất: ràng buộc phạm vi dữ liệu theo tenant.

// permissions.ts — nguồn sự thật duy nhất, dùng chung cả BE lẫn FE
export const PERMISSIONS = {
  COURSE_UPDATE: 'course:update',
  ATTEMPT_READ:  'attempt:read',
} as const;

// courses.controller.ts
@Controller('courses')
export class CoursesController {
  @Patch(':id')
  @RequirePermission(PERMISSIONS.COURSE_UPDATE)
  @PermissionScope('tenant')            // guard buộc phải có tenantId trong ngữ cảnh
  async update(
    @CurrentUser() user: AuthUser,      // user.tenantId lấy từ token, KHÔNG lấy từ body
    @Param('id') id: string,
    @Body() dto: UpdateCourseDto,
  ) {
    // Điều kiện tenantId nằm ngay trong mệnh đề where, không phải kiểm tra sau khi đọc.
    // updateMany + count = 0 -> không tồn tại HOẶC không thuộc tenant này: trả 404 như nhau.
    const res = await this.prisma.course.updateMany({
      where: { id, tenantId: user.tenantId },
      data: dto,
    });
    if (res.count === 0) throw new NotFoundException();
    return this.prisma.course.findFirst({ where: { id, tenantId: user.tenantId } });
  }
}

Hai chi tiết đáng chú ý. Thứ nhất, tenantId lấy từ token đã xác thực, không bao giờ lấy từ body hay query string — nếu client gửi được tenantId thì client chọn được tenant. Thứ hai, khi bản ghi không thuộc tenant hiện tại, chúng tôi trả 404 chứ không 403. Trả 403 vô tình xác nhận "ID này có tồn tại, chỉ là anh không được xem" — một kênh rò rỉ nhỏ nhưng đủ để dò ID.

Frontend vẫn nhận danh sách quyền, nhưng chỉ dùng để ẩn/hiện menu và nút. Đó là trải nghiệm, không phải bảo mật. Ẩn nút "Xoá hợp đồng" giúp người dùng không bấm nhầm; nó không ngăn được ai mở DevTools, đọc bundle JavaScript, tìm endpoint rồi gọi thẳng. Nếu API không tự chặn, nút bị ẩn không có giá trị bảo vệ.

Kiểm soátClient (gợi ý UX)Server (kiểm soát thật)
Phân quyềnẨn/hiện menu, nútGuard @RequirePermission chặn request
Cô lập tenantKhông có vai trò gìtenantId từ token, nằm trong where
Kiểu & kích thước fileLọc trong input file để báo sớmHeadObject sau khi upload
Hạn nộp bàiĐếm ngược cho người dùng thấySo expiresAt chốt lúc bắt đầu
Tính điểm, tính tiềnHiển thị dự kiếnTính lại toàn bộ, bỏ qua số client gửi
Rate-limitChặn double-clickĐếm theo IP qua Redis

3. Cache: nơi dữ liệu rò rỉ mà không ai đụng vào code

Đây là một trong những sự cố đáng nhớ nhất của chúng tôi. Nó không đến từ logic nghiệp vụ, mà đến từ cấu hình cache.

Z-EDU có ba tầng cache. Tầng ngoài cùng là Cache-Control đặt qua decorator @HttpCache trên các route công khai, để CDN/Traefik trả thẳng, request thậm chí không tới được API. Đây là tầng chịu tải chính, và cũng chính vì hiệu quả của nó mà chúng tôi từng có ý định bật nhanh cho tất cả route @Public.

Vấn đề là @Public trong hệ thống của chúng tôi chỉ có nghĩa "không bắt buộc JWT", không có nghĩa là "ai xem cũng giống nhau". Có những route @Public vẫn trả dữ liệu riêng của một người, được bảo vệ bằng token trong URL hoặc bằng ngữ cảnh phiên làm bài. Ví dụ điển hình là GET /exams/public/attempts/:id, tức bài làm của một thí sinh. Nếu route đó bị gắn Cache-Control: public, proxy có thể giữ phản hồi lại và phát cho người tiếp theo truy cập cùng URL. Dữ liệu rò rỉ dù code nghiệp vụ không sai.

// SAI: quét toàn bộ route @Public rồi gắn cache đại trà
@Public()
@HttpCache({ public: true, maxAge: 60 })   // <-- thảm hoạ nếu route trả dữ liệu cá nhân
@Get('exams/public/attempts/:id')
getAttempt(@Param('id') id: string) { /* bài làm của MỘT thí sinh */ }


// ĐÚNG: opt-in từng route, và phân biệt rõ public vs private
@Public()
@HttpCache({ public: true, maxAge: 300 })  // nội dung ai xem cũng giống nhau
@Get('exams/public/:slug')
getExam(@Param('slug') slug: string) { /* đề thi công khai */ }

@Public()
@NoStore()                                 // Cache-Control: no-store, private
@Get('exams/public/attempts/:id')
getAttempt(@Param('id') id: string) { /* mặc định: KHÔNG cache */ }

Hai khái niệm cần tách bạch dứt khoát:

Vary là công cụ bổ trợ: nó bảo cache rằng phản hồi phụ thuộc vào header nào (ví dụ Vary: Authorization, Vary: Accept-Language), nên hai request khác nhau ở header đó phải là hai entry cache khác nhau. Nhưng đừng coi Vary là lưới an toàn. Nó dễ bị bỏ sót, và nếu dữ liệu phân biệt theo token nằm trong URL thì Vary chẳng cứu được gì. Nguyên tắc bền hơn là mặc định không cache, opt-in từng route một, và người thêm @HttpCache phải trả lời được câu hỏi: "phản hồi này có giống hệt nhau với mọi người ẩn danh không?" Nếu còn ngập ngừng thì không cache.

Đánh đổi ở đây rất rõ: opt-in từng route nghĩa là bạn bỏ lỡ vài cơ hội cache và phải chịu thêm tải. Chúng tôi chấp nhận đánh đổi đó, vì cái giá của chiều ngược lại là phát bài làm của thí sinh này cho thí sinh khác.

4. Upload file: đừng tin Content-Type của client

Trong Z-EDU, trình duyệt PUT thẳng file lên S3 (RustFS, S3-compatible) qua presigned URL. API không nằm giữa thread byte — nó chỉ ký URL và, quan trọng hơn, kiểm tra lại ở bước đăng ký.

Vì sao không tin Content-Type client gửi? Vì đó chỉ là một chuỗi trong header HTTP, do client tự khai. Bất kỳ ai cũng gửi được Content-Type: image/png kèm nội dung là một file thực thi, hoặc khai Content-Length nhỏ trong lúc đăng ký rồi upload file 5GB. Kích thước và kiểu file chỉ có ý nghĩa khi được đọc từ object đã nằm trên storage, tức là từ nguồn mà client không viết lại được. Đó là việc của HeadObject.

@Post('uploads/presign')
@RequirePermission(PERMISSIONS.FILE_UPLOAD)
async presign(@CurrentUser() user: AuthUser, @Body() dto: PresignDto) {
  const key = `t/${user.tenantId}/${randomUUID()}`;   // key luôn có tiền tố tenant
  const url = await this.s3.getSignedUrl('putObject', {
    Key: key, Expires: 300,                            // URL ký sống 5 phút
  });
  return { key, url };
}

@Post('uploads/commit')
@RequirePermission(PERMISSIONS.FILE_UPLOAD)
async commit(@CurrentUser() user: AuthUser, @Body() dto: CommitDto) {
  if (!dto.key.startsWith(`t/${user.tenantId}/`)) throw new ForbiddenException();

  // Sự thật nằm ở đây: đọc metadata TỪ STORAGE, không từ những gì client khai.
  const head = await this.s3.headObject({ Key: dto.key });

  if (head.ContentLength > MAX_SIZE)                  throw new BadRequestException('File quá lớn');
  if (!ALLOWED_TYPES.includes(head.ContentType ?? '')) throw new BadRequestException('Kiểu file không hợp lệ');

  return this.files.register({ key: dto.key, tenantId: user.tenantId, size: head.ContentLength });
}

Nếu client khai gian, object vẫn có thể nằm trên S3, nhưng sẽ không được đăng ký vào DB. Nó chỉ là file rác và job dọn dẹp định kỳ sẽ xoá sau đó. Kẻ tấn công tốn băng thông của họ, còn hệ thống không cấp quyền sử dụng file.

Chiều tải xuống cũng vậy: bucket không mở công khai. File được phục vụ qua URL ký ngắn hạn, cấp sau khi API đã kiểm tra người dùng có quyền đọc file đó file thuộc đúng tenant. URL ngắn hạn không phải là bảo mật hoàn hảo — trong khoảng thời gian còn hiệu lực, ai cầm được URL đều tải được. Đánh đổi nằm ở thời hạn: quá ngắn thì link chết giữa chừng khi mạng chậm, quá dài thì cửa sổ rò rỉ rộng ra. Vài phút là điểm cân bằng hợp lý cho phần lớn trường hợp.

Lưu ý: HeadObject đọc ContentType mà S3 lưu, và giá trị này vẫn bắt nguồn từ lúc upload. Nó giúp chặn việc khai gian ở bước đăng ký, nhưng chưa phải kiểm tra nội dung thật của file. Với dữ liệu nhạy cảm hơn, lớp tiếp theo nên là đọc magic bytes hoặc quét virus trong worker. Đây là phòng thủ theo mức độ rủi ro, không phải một công tắc bật/tắt duy nhất.

5. Không tin đồng hồ của client

Trong Z-EDU, khi thí sinh bắt đầu một bài test, server chốt expiresAt ngay tại thời điểm đó và lưu vào bản ghi lượt làm bài. Client nhận về mốc thời gian đó và chỉ làm đúng một việc: đếm ngược tới nó cho người dùng nhìn. Khi nộp bài, server so thời điểm nhận request với expiresAt đã lưu — không đọc bất kỳ trường thời gian nào client gửi lên. Nộp muộn quá 60 giây bị từ chối.

Vì sao có 60 giây khoan dung? Vì đồng hồ trên máy client có thể lệch, mạng có thể chậm, và request còn đi qua nhiều tầng proxy. Nếu từ chối tuyệt đối theo mili-giây, thí sinh trung thực nhưng mạng kém có thể mất bài. Con số 60 giây là một đánh đổi có chủ đích: chấp nhận một cửa sổ nhỏ có thể bị lạm dụng để tránh phạt oan người dùng thật. Quan trọng nhất là quyết định này nằm ở server, có giới hạn và đo được, chứ không phụ thuộc vào Date.now() của trình duyệt.

Mở rộng ra: mọi mốc thời gian trong hệ thống lưu ở UTC, chỉ quy đổi UTC+7 khi hiển thị và khi sinh lịch học hay cắt kỳ báo cáo. Trộn múi giờ trong tầng lưu trữ là một nguồn bug âm ỉ, và trong nghiệp vụ tính hạn nộp hay chốt sổ, một bug múi giờ có hậu quả không khác gì một lỗ hổng bảo mật.

Quy tắc chung: mọi thứ quyết định tiền, điểm, hoặc quyền đều tính ở server. Client được phép biết kết quả, không được phép quyết định kết quả.

6. Rate-limit và triết lý fail-open

Z-EDU rate-limit theo IP và đếm qua Redis. Khi Redis gặp sự cố, chúng tôi chọn fail-open: request vẫn đi qua thay vì bị chặn. Lý do là rate-limit ở đây dùng để giảm lạm dụng, không phải để xác thực danh tính. Nếu fail-closed, một lỗi ở tầng phụ trợ có thể làm toàn bộ API từ chối người dùng thật. Triết lý này cũng xuất hiện ở các phần khác của Z-EDU: Redis lỗi thì cache xuống thẳng DB (chậm hơn, nhưng đúng), ghi tiến độ chuyển sang ghi thẳng DB (có thể mất vài giây vị trí video, nhưng không mất điểm).

async consume(ip: string): Promise<boolean> {
  try {
    const n = await this.redis.incr(`rl:${ip}`);
    if (n === 1) await this.redis.expire(`rl:${ip}`, WINDOW_SEC);
    return n <= LIMIT;
  } catch (err) {
    // Redis chết -> FAIL-OPEN. Không tự làm sập API vì một tầng phụ trợ.
    this.logger.error({ err }, 'rate-limit backend down, failing open');
    this.metrics.increment('ratelimit.fail_open');   // phải có cảnh báo trên chỉ số này
    return true;
  }
}

Nhưng fail-open là một đánh đổi có ý thức, không phải mặc định vô hại. Nó đổi một phần khả năng chống lạm dụng lấy tính sẵn sàng. Trong lúc Redis lỗi, ai đó có thể gửi request nhiều hơn bình thường. Chúng tôi chấp nhận vì cái giá của việc API ngừng phục vụ toàn bộ tenant lớn hơn. Phần bù lại là đếm số lần fail-open và dựng cảnh báo trên chỉ số đó. Fail-open âm thầm mới là nguy hiểm.

Ranh giới rất rõ ràng: fail-open cho các cơ chế bảo vệ hiệu năng, fail-closed cho các cơ chế quyết định quyền. Nếu dịch vụ kiểm tra quyền không trả lời được, câu trả lời phải là "từ chối", không phải "cho qua". Nếu cổng thanh toán không xác nhận được giao dịch, đơn hàng phải ở trạng thái chờ, không được đánh dấu đã thanh toán. Nếu không xác thực được JWT vì không lấy được khoá công khai, request phải bị từ chối. Câu hỏi để phân loại: "Nếu thành phần này im lặng, hệ quả tệ hơn là chặn nhầm người dùng thật, hay là cho qua nhầm kẻ không có quyền?" Câu trả lời quyết định hướng fail.

7. Danh tính tập trung: khoá một chỗ, cắt sạch quyền

Z-Cloud Workspace có 11 ứng dụng tích hợp — chat, mail, docs, files, tasks, calendar, meetings, base, workflow, board, contacts. Nếu mỗi ứng dụng tự quản lý tài khoản, thì khi một nhân viên nghỉ việc, đội IT phải nhớ khoá tài khoản ở 11 chỗ. Trong thực tế, họ sẽ quên ít nhất một chỗ — và cái chỗ bị quên thường là chỗ có dữ liệu nhạy nhất.

Chúng tôi giải quyết bằng SSO qua Keycloak/OIDC: một lần đăng nhập cho toàn bộ ứng dụng, và quan trọng hơn, một nơi duy nhất để thu hồi. Vô hiệu hoá tài khoản trong Keycloak là cắt quyền trên cả 11 app. Đây là lợi ích thường bị bỏ quên khi nói về SSO — người ta hay nhắc tới sự tiện lợi của "đăng nhập một lần", trong khi giá trị bảo mật quan trọng hơn nằm ở "thu hồi một lần".

Đánh đổi là danh tính tập trung tạo ra một điểm phụ thuộc chung. Nếu Keycloak gặp sự cố, người dùng mới không đăng nhập được. Bù lại, phiên đang hoạt động vẫn chạy tới khi token hết hạn. Vì vậy thời hạn token là tham số cần cân nhắc kỹ: token dài thì thu hồi quyền có độ trễ; token ngắn thì hệ thống phụ thuộc nhiều hơn vào độ sẵn sàng của IdP. Không có lựa chọn miễn phí, chỉ có lựa chọn phù hợp với mô hình rủi ro.

Phía Z-Auto, mỗi showroom có storefront riêng trên subdomain, hoặc trỏ tên miền riêng (custom domain). Điều này thêm một bề mặt cần cẩn thận: tenant được xác định từ host. Việc phân giải host thành tenant phải làm ở server và phải tra từ danh sách domain đã đăng ký, chứ không suy diễn bằng cách cắt chuỗi. Và một khi đã có tenantId từ host cho các trang công khai, dữ liệu quản trị vẫn phải lấy tenantId từ token của người đăng nhập — hai nguồn này không được lẫn vào nhau. Tầng CDN/WAF đứng trước API Gateway (OpenResty) trong Z-Cloud Workspace cũng đóng vai trò tương tự: nó là lớp lọc, không phải lớp phân quyền.

Cuối cùng, chat trong Z-Cloud Workspace mã hoá đầu-cuối (E2E) — nghĩa là ngay cả chúng tôi, với tư cách nhà vận hành, cũng không đọc được nội dung. Đây là lựa chọn thiết kế có cái giá của nó: mất khả năng tìm kiếm phía server, mất khả năng khôi phục khi người dùng mất khoá. Chúng tôi chấp nhận cái giá đó cho kênh chat vì tính riêng tư của nội dung trao đổi nội bộ đáng giá hơn.

8. Checklist bảo mật multi-tenant

Đây là danh sách chúng tôi dùng khi review một endpoint mới trong hệ thống multi-tenant. Nó được giữ ngắn có chủ đích, vì checklist quá dài thường không được đọc đến nơi đến chốn.

Kết luận

Không có hệ thống nào an toàn tuyệt đối. Điều thực tế hơn mà chúng tôi theo đuổi là: làm cho lựa chọn an toàn trở thành mặc định. Cache tắt sẵn và phải bật thủ công. Guard bắt buộc khai báo phạm vi. Metadata file luôn được đọc lại từ storage. Mốc thời gian luôn được chốt ở server.

Vì rò rỉ dữ liệu trong SaaS multi-tenant thường không đến từ một cuộc tấn công xuất sắc. Nó thường đến từ một lập trình viên giỏi nhưng đang vội, vào cuối ngày làm việc. Mặc định an toàn của hệ thống là thứ còn bảo vệ bạn trong những lúc như vậy.

Z-SOFT xây dựng những hệ thống như thế này

Kỹ thuật trong bài đang chạy thật trong sản phẩm của chúng tôi. Nếu doanh nghiệp của bạn cần một hệ thống chịu tải, bảo mật và vận hành được lâu dài, hãy trao đổi với đội kỹ thuật Z-SOFT.

Bài liên quan