Chỉ một dòng mã có thể phá hủy toàn bộ mô hình kiểm soát truy cập của bạn. Đưa trực tiếp request body vào một lệnh cập nhật cơ sở dữ liệu, và bạn đã trao cho client một cây bút để viết lại schema của mình. Đó chính là Mass Assignment. Đây không phải là một lỗi kỳ lạ hay một trường hợp hiếm gặp. Đó là một lỗi thiết kế xuất hiện bất cứ khi nào một API coi một payload là chính chính sách cập nhật của nó.

await db.users.update(req.params.id, { ...req.body });

Trông nó có vẻ gọn gàng. Nó giúp tiết kiệm thời gian gõ phím. Nhưng client mới là bên kiểm soát các key. Một kẻ tấn công có thể thêm "role": "admin", "accountId": "someone_else", hoặc "credit": 99999" vào một bản cập nhật hồ sơ thông thường. Lớp kiểm tra tính hợp lệ (validation layer) của bạn có thể kiểm tra xem các giá trị đó có phải là chuỗi hay số và cho rằng chúng ổn. Tuy nhiên, tính hợp lệ (validity) không đồng nghĩa với việc được ủy quyền (authorization). Một người dùng có thể sở hữu bản ghi mục tiêu một cách hợp lệ, nhưng điều đó không có nghĩa là họ có quyền chỉnh sửa mọi trường bên trong đó.

Mass Assignment Thực Sự Trông Như Thế Nào

Nguy hiểm ẩn giấu trong sự tiện lợi. Các framework và ORM giúp việc ánh xạ trực tiếp các JSON key vào các cột cơ sở dữ liệu trở nên cực kỳ dễ dàng. Khi bạn làm điều này, bạn đang bảo cơ sở dữ liệu hãy tin tưởng client về việc cái gì nên thay đổi, chứ không chỉ là thay đổi như thế nào.

Một người dùng khi cập nhật hồ sơ có thể gửi dữ liệu hợp lệ cho displayNamebio, nhưng lại lén chèn thêm role hoặc balance đi kèm. Nếu controller của bạn chỉ đơn giản là chuyển tiếp đối tượng đó, cơ sở dữ liệu sẽ ghi lại tất cả. Việc kiểm tra tính hợp lệ (validation) sẽ bắt được các giá trị sai định dạng, nhưng hiếm khi bắt được các key độc hại. Quy tắc nghiệp vụ nói rằng "người dùng này được phép cập nhật hồ sơ của họ" vô tình trở thành một quyền hạn bao quát cho mọi cột trong hàng dữ liệu đó.

Giải pháp không phải là thêm nhiều bước kiểm tra tính hợp lệ hơn. Đó là một kiến trúc chặt chẽ hơn.

Ba Cánh Cổng Bảo Vệ

Một thao tác thay đổi (mutation) an toàn phải đi qua ba bước kiểm tra riêng biệt trước khi chạm tới bộ lưu trữ.

Các Trường Được Chấp Nhận (Allowlist)

Hãy bắt đầu bằng việc quyết định chính xác những key nào mà bạn sẽ xem xét. Nếu một trường không nằm trong danh sách cho phép (allowlist), hãy từ chối yêu cầu hoặc loại bỏ key đó. Điều này đảo ngược trạng thái mặc định: các cột cơ sở dữ liệu mới sẽ không thể ghi cho đến khi lập trình viên chủ động công khai chúng. Schema sẽ phát triển theo thời gian. Một đồng nghiệp thêm vào stripeCustomerId, departmentBudget, hoặc một cờ isVerified. Với một allowlist, những cột mới đó sẽ tự động được bảo vệ khỏi các thao tác ghi từ client. Nếu không có nó, mỗi cột mới đều trở thành một bề mặt API vô tình bị lộ ra.

Các Giá Trị Hợp Lệ

Khi bạn đã biết những trường nào được phép, hãy kiểm tra xem các giá trị đó có hợp lý hay không. Chuỗi múi giờ có thực sự là một múi giờ được công nhận không? Email có đúng định dạng không? Con số có nằm trong phạm vi hợp lý không? Đây là bước vệ sinh dữ liệu. Nó ngăn chặn rác đi vào hệ thống của bạn, nhưng nó không ngăn chặn sự lạm dụng. Một chuỗi "admin" hoàn toàn hợp lệ về mặt định dạng vẫn sẽ cực kỳ nguy hiểm trong trường role nếu sai người gửi nó.

Các Thay Đổi Được Ủy Quyền

Đây là cánh cổng mà hầu hết các đội ngũ thường bỏ qua, và cũng là nơi sự bảo vệ thực sự tồn tại. Hãy đặt một câu hỏi chi tiết: liệu tác nhân cụ thể này có quyền thay đổi trường cụ thể này trên bản ghi cụ thể này không? Đừng hỏi "người dùng có phải là admin không?" hay "người dùng có phạm vi write:users không?". Thay vào đó, hãy hỏi: "người dùng này có được phép thay đổi displayName của chính họ, nhưng không bao giờ được thay đổi accountId không?". Việc ủy quyền theo từng trường (per-field authorization) giúp ngăn chặn các quyền rộng như "Editor" hay "User" trở thành chìa khóa vạn năng cho mọi thuộc tính trong hàng dữ liệu.

Xây Dựng Hàm Patch

Hãy kết nối ba cánh cổng thành một quy trình duy nhất. Khi một yêu cầu patch đến, hãy chạy nó qua các giai đoạn theo thứ tự.

Đầu tiên, lọc đầu vào dựa trên allowlist của bạn. Nếu role không phải là một trường được phép cho endpoint này, hãy dừng lại ngay lập tức. Không có lý do gì để kiểm tra tính hợp lệ hoặc ủy quyền cho một giá trị mà lẽ ra bạn không bao giờ được nhận.

Thứ hai, kiểm tra tính hợp lệ của các giá trị được cho phép. Kiểm tra kiểu dữ liệu, định dạng và các quy tắc nghiệp vụ. Một trường vị trí phải là một chuỗi tương ứng với một múi giờ thực tế. Một URL ảnh đại diện phải là một URI hợp lệ với độ dài nhất định.

Thứ ba, ủy quyền hành động. Xác minh rằng tác nhân sở hữu bản ghi mục tiêu, hoặc nắm giữ chính xác quyền hạn được yêu cầu cho trường này. Quyền sở hữu là một mặc định tốt cho dữ liệu cá nhân, nhưng một số trường vẫn cần thêm các cánh cổng bổ sung. Một người dùng có thể sở hữu hồ sơ của họ, nhưng chỉ quản trị viên thanh toán mới được phép chạm vào taxRegion.

Thứ tư, chuẩn hóa dữ liệu. Cắt bỏ khoảng trắng, thu gọn các khoảng trắng lặp lại, chuyển email thành chữ thường, hoặc loại bỏ các ký tự điều khiển. Hãy thực hiện việc này sau khi kiểm tra tính hợp lệ nhưng trước khi lưu trữ để bạn không phải so sánh các chuỗi dữ liệu "bẩn" trong quá trình kiểm tra ủy quyền.

Nếu đầu vào không vượt qua bất kỳ cổng kiểm soát nào, hãy từ chối toàn bộ mutation. Đừng chỉ áp dụng một phần các trường an toàn và âm thầm loại bỏ các trường lỗi. Một phản hồi hỗn hợp sẽ huấn luyện các client thử rải mọi key mà chúng có thể nghĩ ra để xem cái nào có tác dụng. Hãy để lỗi xảy ra một cách rõ ràng.

Những trường hợp biên thực sự quan trọng

Các biện pháp phòng chống mass assignment sống còn dựa trên những chi tiết mà các unit test thường bỏ lỡ.

Các key JSON trùng lặp. Kẻ tấn công có thể gửi các payload như {"role": "user", "role": "admin"}. Tùy thuộc vào HTTP parser và framework của bạn, giá trị thứ hai có thể ghi đè lên giá trị đầu tiên trước khi mã ứng dụng của bạn nhìn thấy đối tượng. Hãy kiểm tra hành vi này ở cấp độ parser. Nếu framework của bạn âm thầm chấp nhận key cuối cùng, allowlist của bạn có thể đang nhìn vào "user" trong khi cơ sở dữ liệu lại nhận "admin".

Các đối tượng lồng nhau, giá trị null và mảng. Đừng giả định rằng payload là dạng phẳng (flat). Một client có thể bao bọc một trường bị hạn chế bên trong một đối tượng lồng nhau như { "profile": { "role": "admin" } }. Allowlist của bạn phải có khả năng đệ quy nếu schema của bạn có. Tương tự, hãy quyết định cách bạn xử lý null. Nó có nghĩa là "bỏ qua trường này" hay "xóa trường này"? Và nếu một mảng được mong đợi, liệu validator của bạn có từ chối các cấu trúc không mong muốn, hay nó sẽ ép kiểu một đối tượng đơn lẻ thành một mảng và cho phép nó đi qua?

Chuẩn hóa Unicode. Hai chuỗi ký tự có thể trông giống hệt nhau đối với con người nhưng lại là các chuỗi byte khác nhau. Một người dùng có thể gửi một ký tự é đã được tổ hợp (precomposed) hoặc một ký tự e đã được phân tách (decomposed) cộng với dấu phụ. Nếu kiểm tra phân quyền của bạn chuẩn hóa một lần nhưng lớp lưu trữ lại chuẩn hóa theo cách khác, bạn có thể gặp phải dữ liệu không nhất quán hoặc tệ hơn là một lỗ hổng bypass, nơi sự trùng lặp tên người dùng lọt qua logic của bạn. Hãy chuẩn hóa sớm và chuẩn hóa một cách nhất quán.

Race conditions. Các quyết định phân quyền không phải là những khung hình tĩnh. Chúng xảy ra tại một thời điểm nhất định. Hai yêu cầu có thể đọc cùng một bản ghi, cả hai đều thấy rằng người thực hiện được phép ghi, và cả hai đều thực hiện cập nhật. Trong khoảng thời gian đó, trạng thái hoặc quyền của người thực hiện có thể đã thay đổi. Luôn áp dụng các cập nhật cơ sở dữ liệu với một điều kiện về số phiên bản (version number) hoặc giá trị máy trạng thái (state machine). Hãy sử dụng thứ gì đó như UPDATE users SET ... WHERE id = ? AND version = 5. Nếu hàng đó đã thay đổi kể từ khi bạn đọc nó, việc ghi sẽ thất bại. Hãy xử lý lỗi bằng cách thử lại hoặc từ chối. Điều này giúp ngăn chặn các kiểm tra phân quyền lỗi thời làm hỏng dữ liệu của bạn.

Giám sát những gì quan trọng

Bạn không thể bảo mật những gì bạn không thể nhìn thấy. Hãy xây dựng nhật ký kiểm tra (audit logging) xoay quanh quyết định, chứ không chỉ là hành động.

Hãy ghi lại actor ID và target ID. Ghi lại chính xác tên các trường đã được chấp nhận và các trường bị từ chối. Ghi lại phiên bản chính sách (policy version) đã đưa ra quyết định và kết quả cuối cùng. Nếu một người dùng đột nhiên bắt đầu bị từ chối trường role khi cập nhật hồ sơ, bạn sẽ muốn biết ngay lập tức.

Đừng bao giờ ghi lại bearer tokens. Đừng bao giờ đổ toàn bộ request bodies vào nhật ký của bạn. Một audit trail nên giúp bạn điều tra các hành vi lạm dụng, chứ không phải trở thành một kho lưu trữ thông tin xác thực và dữ liệu cá nhân.

Quy tắc duy nhất bạn cần

Một request body chỉ đề xuất dữ liệu. Nó không bao giờ tự định nghĩa quyền hạn của chính nó. Client có thể yêu cầu bất cứ điều gì. Máy chủ của bạn sẽ quyết định, theo từng trường và từng hàng, những gì được phép lưu vào bộ nhớ vĩnh viễn. Hãy xây dựng các patch của bạn với tư duy tách biệt đó, và mass assignment sẽ trở thành một vấn đề mà bạn đã ngăn chặn được từ lâu trước khi nó chạm tới lớp phân quyền của mình.