0

Kiểm thử tính cách ly storage ở front-end cho SaaS đa tenant: cookie phân vùng, scope của Service Worker và biên giới profile

Bạn chạy hai tài khoản của hai tenant khác nhau trên cùng một trình duyệt, đăng nhập liên tiếp, sau đó mở DevTools và kiểm tra localStorage, IndexedDB, Cache API. Kết quả: tenant B nhìn thấy một phần dữ liệu của tenant A còn sót lại. Không phải do lỗi server, mà do cách front-end tổ chức key và cách trình duyệt phân vùng storage. Với SaaS đa tenant, đây là lỗ hổng riêng tư người dùng nghiêm trọng, và nó thường trượt qua các bài test vì chúng ta hay test "sau khi dọn dẹp thủ công" thay vì "trong môi trường thực tế của người dùng".

Bài viết này đi từ một kịch bản test cụ thể, chỉ ra các điểm rò rỉ phổ biến, giải thích ranh giới cách ly thực sự của trình duyệt, đề xuất chiến lược test E2E dựa trên profile trình duyệt độc lập, và cuối cùng giới thiệu cách dùng BitBrowser Local API để biến từng profile thành một "back-end cách ly" mà framework test quản lý theo vòng đời.

1. Kịch bản test: hai tenant, một trình duyệt, dữ liệu còn sót lại ở đâu?

Giả sử ứng dụng của bạn phục vụ nhiều tenant qua cùng một origin app.example.com, phân biệt tenant bằng subdomain hoặc bằng path.

Cụ thể, bài test sẽ đăng nhập tenant acme bằng tài khoản A, thực hiện vài hành động sinh dữ liệu: ghi tùy chọn giao diện vào localStorage, ghi một bản ghi vào IndexedDB, tải và cache vài tài nguyên tĩnh qua Service Worker. Sau đó đăng xuất, đăng nhập tenant globex bằng tài khoản B trên cùng profile. Bước cuối cùng mới quyết định: kiểm tra phía globex, xem localStorage có còn sót tùy chọn của acme, IndexedDB có truy vấn được bản ghi của acme, Cache API có trúng cache của acme hay không. Mục tiêu khẳng định rất rõ: tất cả những chỗ đó phải rỗng, chỉ cần một chỗ không rỗng là có rò rỉ dữ liệu giữa các tenant.

Các điểm rò rỉ phổ biến

a) Key storage không có tiền tố tenant. Đây là lỗi hay gặp nhất. Front-end lưu tùy chọn giao diện vào localStorage mà không gắn tenant ID. Vì localStorage chỉ cách ly theo origin, hai tenant trên cùng origin chia sẻ cùng một khoá. Nếu key chỉ là ui.prefs, tenant B đọc được ngay giá trị của tenant A. Sửa đơn giản: tiền tố key bằng tenant ID, ví dụ acme:ui.prefs. Nhưng hãy xem điểm b.

b) Tenant ID nằm ở path, còn cache key thì không. Ứng dụng định tuyến tenant bằng path (/acme, /globex), nhưng Service Worker cache tài nguyên tĩnh với key không mang tenant. Vì Cache API cách ly theo origin chứ không theo path, cả hai tenant đọc chung một bản file đã cache. Nếu bản đó nhúng bất kỳ dữ liệu tenant nào (ví dụ một bundle có hard-code config), nó sẽ bị chia sẻ sai. Thêm nữa, nếu bạn cache response của API theo URL mà URL có chứa tenant ID, việc cache key thiếu tenant sẽ khiến tenant B nhận response của tenant A.

c) IndexedDB database name không có tenant. Ứng dụng mở một database chung tên myapp để lưu dữ liệu. Nếu bạn lưu dữ liệu offline của tenant vào database chung với tên cố định, tenant B truy vấn được record của tenant A. Đặt tên database theo tenant: myapp-acme.

d) Cookie không phân vùng (non-partitioned) trong iframe/cross-site. Khi nhúng một component cross-site (ví dụ widget thanh toán, embedded analytics) vào nhiều tenant, một cookie thông thường sẽ bị chia sẻ giữa các context, hoặc bị chặn hoàn toàn khi trình duyệt tắt third-party cookie. Đây là lúc CHIPS (Partitioned cookie) và Storage Access API vào cuộc, nhưng nếu test chỉ chạy trên một profile "sạch thủ công", bạn sẽ không bắt được lỗi này.

2. Ranh giới cách ly thực sự của trình duyệt

Hiểu sai ranh giới cách ly là gốc rễ của các test sai. Dưới đây là những gì trình duyệt thực sự bảo vệ, và những chỗ nó không bảo vệ.

Origin là nền tảng, nhưng không phải là toàn bộ

Theo spec, localStorage, sessionStorage, IndexedDB, và Cache API đều cách ly theo origin (scheme + host + port). Hai trang khác origin hoàn toàn không chia sẻ storage. Nhưng vấn đề của SaaS đa tenant là: các tenant thường cùng một origin (chia sẻ subdomain hoặc path). Lúc này origin isolation không giúp gì, và trách nhiệm cách ly rơi vào tay ứng dụng (tiền tố key, tên database). Nếu ứng dụng làm sai, dữ liệu sẽ trộn.

Subdomain và path: cùng origin, rủi ro khác nhau

  • Subdomain khác, origin khác. acme.app.example.com và globex.app.example.com là hai origin khác nhau. localStorage của chúng không chia sẻ. Tuy nhiên, bạn có thể chủ động chia sẻ bằng cookie có Domain cha. Nếu session cookie của tenant được set với Domain cha và không có flag Partitioned, một tenant có thể đọc cookie của tenant kia.
  • Path khác, origin giống hệt. /acme và /globex trên cùng host là một origin. Mọi storage chia sẻ. Path chỉ là khái niệm routing của ứng dụng, trình duyệt không dùng path để cách ly.

Service Worker scope: có thể vượt qua path

Service Worker được đăng ký với một scope (phạm vi), mặc định là thư mục chứa file sw.js, và scope không thể vượt quá thư mục đó trừ khi dùng header Service-Worker-Allowed. Một SW đăng ký tại / có scope / sẽ kiểm soát fetch của mọi path dưới origin, bao gồm cả /acme và /globex. Điều này nghĩa là:

  • Cache do SW quản lý là chung cho toàn origin (Cache API cách ly theo origin).
  • Nếu SW của bạn cache response API mà không kiểm tra tenant trong logic cache, tenant B có thể phục vụ từ cache của tenant A.
  • Một SW "hỏng" hoặc cũ đăng ký tại scope rộng có thể phục vụ sai nội dung cho tenant không mong muốn, và vì SW sống sót qua navigation, nó không tự biến mất khi bạn chuyển tenant.

Cookie partitioning (CHIPS) và Storage Access API: chi tiết hơn, phức tạp hơn để test

CHIPS cho phép set cookie với thuộc tính Partitioned. Cookie đó được lưu theo partition key = top-level site + current site, nghĩa là cùng một third-party cookie sẽ có bản sao độc lập trong mỗi top-level context. Storage Access API cho phép một trang cross-site xin quyền đọc cookie sau tương tác của user. Cả hai làm isolation tốt hơn, nhưng cũng có nghĩa là:

  • Test case phải mô phỏng đúng top-level context (iframe vs top-level, cross-site vs same-site).
  • Nếu bạn test trên một profile duy nhất và "dọn sạch" thủ công giữa các tenant, bạn bỏ lỡ toàn bộ hành vi phân vùng thực tế, vì partition key phụ thuộc vào context, không phải vào việc bạn xoá cookie hay chưa.

Một lưu ý thực chiến: test hành vi CHIPS của cross-site iframe trong các framework tự động hoá như Playwright rất chông gai. Nhiều trình duyệt mặc định tắt third-party cookie, nên hành vi phân vùng không tự kích hoạt, và các khẳng định dễ sai lệch. Nếu nhất quyết test trên Playwright, bạn thường phải cấu hình thủ công tham số khởi động trình duyệt để cho phép third-party storage (ví dụ tắt bớt vài tính năng chặn mặc định, hoặc cho phép rõ ràng third-party cookie), nếu không kết quả thu được không tin cậy. Cách vừa nhàn vừa sát với môi trường người dùng thật hơn là dùng trực tiếp đa profile của BitBrowser để mô phỏng các top-level context khác nhau, né việc cãi lại chính sách bảo mật mặc định của trình duyệt.

3. Chiến lược test: mỗi tenant một profile trình duyệt độc lập

Cách sai: chạy toàn bộ E2E trên một browser context, giữa các tenant thì gọi một loạt thao tác dọn dẹp (xoá cookie, xoá localStorage, xoá IndexedDB, xoá Cache). Cách này test "tính cách ly sau khi dọn dẹp", không test "tính cách ly trong môi trường người dùng thật", nơi người dùng không bao giờ chủ động xoá storage giữa hai tài khoản, và nơi Service Worker vẫn đang chạy. Tệ hơn nữa là IndexedDB: theo mặc định, trình duyệt rất khó xoá sạch nó chỉ bằng vài dòng code, vì có transaction lock bảo vệ, deleteDatabase thường phải chờ giao dịch hoàn tất, thậm chí thất bại luôn. Vì vậy, đường đi "tự dọn bằng code" vốn dĩ không sạch, mà còn dễ khiến bạn tưởng mình "đã dọn xong".

Cách đúng: mỗi tenant chạy trong một browser profile riêng biệt, không chia sẻ cookie, storage, cache, và đăng ký Service Worker với bất kỳ tenant nào khác.

Tại sao profile-level isolation đúng với semantics thật:

  • Trong thực tế, hai người dùng (hoặc hai tenant của cùng một người) thường dùng hai trình duyệt khác nhau, hoặc hai profile khác nhau. Họ không share cookie jar.
  • Service Worker đăng ký theo origin trong một profile. Một profile khác có bộ SW registration riêng, bộ Cache riêng. Test trên profile riêng biệt sẽ bắt được lỗi mà "clear thủ công" che giấu: ví dụ SW của tenant A còn sống và phục vụ cache cũ khi tenant B xuất hiện trên cùng context.
  • Cookie Partitioned cũng được lưu trong profile; profile khác = partition khác = không rò rỉ.

Một ma trận test gợi ý, bạn có thể xếp theo bốn nhóm sau:

1.** Cùng origin, khác tenant, cùng profile (1 profile)😗* chứng minh ứng dụng tự cách ly bằng key/database prefix, không dựa vào trình duyệt. 2. Cùng origin, khác tenant, khác profile (2 profile): chứng minh ngay cả khi app lỏng lẻo, profile vẫn ngăn rò rỉ. 3. Cross-site component (iframe) có CHIPS (2 top-level context): chứng minh partitioned cookie không cross-leak. 4. Service Worker cache (2 profile): chứng minh SW/cache của tenant A không phục vụ tenant B.

Lưu ý: test case 1 (cùng profile) là để chứng minh ứng dụng làm đúng cách ly ở tầng app. Test case 2 (khác profile) là để chứng minh hệ thống an toàn ngay cả khi app có lỗi. Cả hai đều cần, nhưng nhiều team chỉ làm case 1 và tự huyễn hoặc là "đã test xong".

Khi nào dùng Playwright, khi nào dùng BitBrowser

Nếu bạn chỉ test tính cách ly storage thuần túy, newContext của Playwright là một giải pháp thay thế nhẹ: nó mở một browser context sạch cho mỗi test, cookie và storage không can thiệp nhau, đủ để phủ phần lớn các khẳng định "cách ly tầng ứng dụng", lại khởi chạy nhanh, tốn ít tài nguyên.

Nhưng khi test cần đi xa hơn, context tạm của Playwright bắt đầu hụt:

  • Muốn mô phỏng proxy IP thật, để các tenant trông như đến từ các khu vực khác nhau (ví dụ một bên Đông Nam Á, một bên Bắc Mỹ), Playwright nguyên bản không giỏi chuyện neo proxy vào một môi trường bền vững.
  • Cần persistence xuyên phiên (Persistent Profile), tức là sau khi đóng trình duyệt trạng thái vẫn còn, lần sau dùng lại luôn, thay vì mỗi lần bắt đầu từ con số không.

Hai nhu cầu này lại chính là trường hợp BitBrowser được thiết kế để xử lý: mỗi profile của nó đều có thể gắn một proxy độc lập, persistence độc lập, khớp chuẩn với hai kịch bản "mô phỏng tenant ở khu vực khác nhau" và "giữ trạng thái đăng nhập xuyên phiên". Vậy kết luận không phải chọn một, mà là: cách ly storage thuần túy thì Playwright đủ sức, cần proxy thật và persistence thì lên BitBrowser.

4. Dùng BitBrowser Local API làm back-end profile cách ly cho framework test

Để chạy mỗi tenant trong một profile thật sự, tách biệt hoàn toàn, bạn cần một cơ chế tạo và huỷ profile theo lập trình. Đây là chỗ BitBrowser Local API phát huy tác dụng: nó cho phép framework test quản lý các profile trình duyệt cách ly như những tài nguyên có vòng đời (lifecycle) gắn với bài test.

Mô hình tích hợp gồm bốn bước:

  1. Khởi tạo (setup): Trước khi chạy một test tenant, framework gọi Local API để tạo một profile sạch. Mỗi profile là một môi trường trình duyệt hoàn toàn độc lập: cookie jar riêng, localStorage/IndexedDB/Cache API riêng, và đăng ký Service Worker riêng. Profile được gán một proxy độc lập, mô phỏng tenant ở một khu vực địa lý khác (ví dụ tenant Việt Nam đi qua proxy Hà Nội, tenant Mỹ đi qua proxy California), giúp test cả hành vi dựa trên region. 2.** Tiêm trạng thái đăng nhập (inject)😗* Thay vì đi qua UI login mỗi lần (chậm, dễ không ổn định), framework có thể tiêm trực tiếp trạng thái đăng nhập của tenant đó vào profile vừa tạo, rồi mở profile đó bằng Local API và chạy E2E. Vì profile là độc lập, trạng thái của tenant này không ảnh hưởng tenant kia.
  2. Thực thi (run): Chạy bài test trên profile đó. Do profile tách biệt, mọi rò rỉ storage đều là lỗi ứng dụng, không phải lỗi "chưa clear".
  3. Dọn dẹp (teardown): Sau khi test xong, framework gọi Local API huỷ profile. Profile sạch biến mất cùng toàn bộ cookie/storage/cache bên trong, không để lại bất kỳ tàn dư nào ảnh hưởng bài test sau.

Toàn bộ quy trình này không đòi bạn viết một đống script. Framework gọi API tạo một profile sạch kèm proxy độc lập trước khi test bắt đầu, tiêm trạng thái đăng nhập vào đó, mở nó lên chạy test, và khi test xong gọi API huỷ profile đó cùng toàn bộ cookie, storage, cache bên trong. Mỗi bước gắn với vòng đời của test case, chạy xong là thiêu rụi, không để lại bất kỳ tàn dư nào xuyên test.

Điểm mấu chốt: BitBrowser không phải là "trình duyệt test", nó là back-end quản lý profile cách ly mà code test điều khiển. Bạn viết test một lần, mỗi tenant được cấp một profile riêng qua API, không cần dựa vào trình duyệt của CI agent hay cấu hình thủ công. Với 10 profile miễn phí, bạn đã có đủ để chạy song song một ma trận test nhỏ mà không tốn phí. Tìm hiểu thêm tại BitBrowser.

Kết luận

Cách ly storage trong SaaS đa tenant không phải là chuyện "trình duyệt lo hết". Origin isolation chỉ là nền tảng, và nó vô dụng khi các tenant cùng origin. Rò rỉ thường đến từ những chỗ tinh vi: key thiếu tiền tố tenant, cache key không mang tenant, database IndexedDB chung tên, hay Service Worker scope quá rộng phục vụ sai cache.

Chiến lược test đúng là tách mỗi tenant ra một browser profile độc lập, test trong môi trường giống người dùng thật, rồi dùng một API để quản lý vòng đời profile đó. Khi framework của bạn tạo, tiêm, chạy và huỷ profile theo lập trình, bạn mới thực sự đo lường được tính cách ly, thay vì đo lường "mình đã xoá sạch chưa".


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí