Chúng tôi tự kiểm định máy chủ auth của mình trước khi ra mắt. Các lỗi có chung một hình dạng.
Trước khi đề nghị bất kỳ ai tin tưởng giao thông tin đăng nhập của họ cho mình, chúng tôi đã soát lại máy chủ auth của chính mình theo cách một kẻ tấn công sẽ làm: không phải bằng cách chạy nó, mà bằng cách đọc nó. Kết quả là một bản đánh giá viết ra cho toàn bộ bề mặt: mười sáu mục được đánh số, 261 dòng, từ SAML qua federation OIDC, SCIM, API admin, MFA và công cụ backup, mỗi phát hiện đều kèm một mức nghiêm trọng và một vị trí trong mã nguồn. Tám phát hiện bị đánh dấu là phải sửa trước. Chúng tôi sửa tám cái đó trong một buổi tối và phần còn lại của danh sách nửa tiếng sau. Hai tuần rưỡi sau đó, chúng tôi làm lại đúng bài tập ấy trên control plane đa khách hàng và thu về năm phát hiện nữa, đóng lại vào buổi tối trước khi lên production.
Đây là con số đọng lại. Bộ test báo 367 trên 367 đạt trước buổi tối đó, và 367 trên 367 sau đó. Không có gì chuyển đỏ, bởi vì không có gì trong bộ test nhắm vào bất kỳ chỗ nào trong số đó. Test chứng minh những gì bạn nghĩ ra để test, và không ai từng nghĩ tới việc test xem liệu bỏ đi một thuộc tính XML mà chữ ký không bao phủ có đẩy một assertion phát lại xuống một nhánh dễ dãi hơn hay không.
Đọc như tám lỗi rời rạc, một danh sách như thế này vô dụng với bất kỳ ai khác. Đọc như những hình dạng, nó là ba thói quen, lặp lại trên khắp các hệ thống con, trong mã do những người đã đọc kỹ đặc tả viết ra.
client_id, header chuyển tiếp, InResponseTo và claim email
Thói quen thứ nhất là coi một giá trị do bên gọi ghi ra như một sự thật về chính bên gọi đó. Hai phát hiện mà bản đánh giá đánh dấu Critical mỗi cái đều chỉ là một đầu vào duy nhất: một tham số truy vấn, và một claim email.
Chính sách MFA được suy ra từ client_id phân tích được từ returnUrl trên biểu mẫu đăng nhập, và /connect/authorize không bao giờ kiểm tra lại. Nhắm lần đăng nhập vào một client đã tắt MFA thì phiên đăng nhập đi ra mà không có yếu tố thứ hai, rồi vẫn tiêu dùng ngon lành ở client vốn yêu cầu phải có nó. Cái đó có bài viết riêng.
Các giới hạn theo IP lấy khóa từ địa chỉ remote, còn các header chuyển tiếp thì được tin cậy từ 0.0.0.0/0. Một giá trị X-Forwarded-For cho mỗi request là đủ để biến mọi biện pháp kiểm soát theo IP thành thứ chỉ mang tính tham khảo, và tiện thể viết lại luôn cột IP trong nhật ký audit. Chi tiết ở đây.
Bên tiêu thụ assertion SAML chọn giữa đường SP-initiated (tiêu thụ request id, dùng một lần) và đường IdP-initiated (kiểm tra một cache assertion id) hoàn toàn dựa vào việc response có mang thuộc tính InResponseTo hay không. Thuộc tính đó nằm trên phần tử <Response>, vốn không được bao phủ khi chỉ có assertion được ký, và đó lại là trường hợp phổ biến. Bỏ nó đi thì một response bị bắt lại sẽ đi vào nhánh mà lần đăng nhập gốc chưa bao giờ ghi gì vào cache chống phát lại của nó, trong khi mọi chữ ký vẫn xác minh thành công. Vì sao chữ ký chưa bao giờ là vấn đề.
Và những người dùng federated quay lại thì được phân giải theo email trong assertion, nên bất kỳ kết nối nào cũng có thể khẳng định địa chỉ của người khác và đáp thẳng vào tài khoản của họ. Cái đó cũng đã được viết lại.
API admin là một nhà máy sản xuất ra chính thông tin xác thực của nó
Việc phân quyền admin chỉ dựa vào đúng một thứ: một token mà claim scope của nó mang scope admin. Có hai endpoint admin sẵn sàng đặt scope đó vào một token mới tinh.
POST /api/v1/token, endpoint mạo danh mà công cụ hỗ trợ dùng, tách tham số truy vấn scopes của nó rồi sao chép kết quả nguyên văn vào một access token và một refresh token, không hề đối chiếu với AllowedScopes đã đăng ký của client. Vì vậy một thông tin xác thực admin, dù có ngắn hạn tới đâu, cũng đúc ra được một refresh token admin dài hạn cho bất kỳ người dùng nào và bất kỳ client nào: đặc quyền sống lâu hơn cả việc xoay vòng chính cái thông tin xác thực mà bạn đang lo lắng. Trong khi đó, việc tạo client lại bind thẳng một đối tượng client thô lấy từ body của request, kể cả ClientSecretHashes, nên một admin có thể đăng ký một client client_credentials nắm scope admin cùng một hash bí mật mà họ đã biết sẵn. Các lệnh list và get thì trả lại mọi hash đang lưu.
Endpoint mạo danh giờ từ chối scope admin và giới hạn phần còn lại theo chính AllowedScopes của client, các thao tác tạo và cập nhật bind vào một DTO không có trường hash bí mật, và không response nào mang theo hash. Một quyền có thể tự cấp cho chính nó thì không phải là một quyền.
Một dấu gạch nối, một group không có chủ, và một email làm công việc của một danh tính
Thói quen thứ hai là để một thứ có tên trong đó đứng thay cho một danh tính.
Email chính là khóa nối cho đăng nhập federated: mang tính toàn cục, có mặt ở nhiều nhà cung cấp, và bằng đúng những gì kết nối đang khẳng định nói rằng nó là. Cách sửa là phân giải theo cặp nhà cung cấp và subject, thứ mà không kết nối nào khác đúc ra được.
Trong control plane, cái tên đó là slug của tenant. Bộ định tuyến biến một slug thành tiền tố bảng lưu trữ đã loại bỏ dấu gạch nối, nên acme-corp và acmecorp cho ra cùng một tiền tố, tức là cùng một bộ bảng và cùng một khóa ký Vault. Hai tenant có thể đăng ký riêng biệt lại dùng chung một bộ bảng và một khóa ký. Đăng ký cái thứ hai là bạn đang nhìn vào người dùng của cái thứ nhất. Cách sửa không phải là một hàm sinh tiền tố thông minh hơn. Nó là cấm dấu gạch nối trong slug để ánh xạ trở thành đơn ánh, và chuyện này đáng đọc bản dài hơn.
SCIM là cái làm chúng tôi xót nhất, bởi vì một nửa của nó đã đúng. Mọi thao tác đọc và ghi người dùng đều kiểm tra lại rằng tài nguyên đó đã được cấp phát bởi chính client đang gọi. Còn group, nằm ở file ngay bên cạnh, thì hoàn toàn không có chủ: lệnh tạo không bao giờ đóng dấu chủ sở hữu, lệnh list truyền null làm bộ lọc chủ sở hữu rồi trả về mọi group trong môi trường, còn get, replace, patch và delete thì chấp nhận bất kỳ id nào. Biết được id của một group chính là toàn bộ phần phân quyền, và ở nơi nhiều client cấp phát dùng chung một môi trường thì đó là việc đọc và ghi thành viên group xuyên client, mà đó lại chính là cách các vai trò được cấp. Group giờ mang theo client đã tạo ra chúng, và mọi thao tác đọc và ghi đều bị giới hạn theo client đó.
Cookie đã được ký trước khi webhook kịp trả lời
Thói quen thứ ba là làm việc không thể đảo ngược trước khi chạy phần kiểm tra.
Ví dụ rõ ràng nhất là một tính năng khách hàng bỏ tiền ra mua: một tenant đăng ký một webhook được hỏi "cho phép lần đăng nhập này chứ?" vào thời điểm xác thực, và có thể đặt nó ở chế độ bắt buộc tuân theo câu trả lời. Hook đó chạy trên mọi đường thiết lập phiên, hoàn toàn đúng. Chỉ có điều nó chạy sau SignInAsync. Nên khi bị từ chối thì nó ném lỗi, request trả về 500, và trình duyệt thì đã cầm sẵn một cookie phiên đã ký. Bất kỳ client nào bỏ qua lỗi đó đều đã đăng nhập, dù bị chặn hay không. Cái webhook chặn đăng nhập lại không chặn được gì.
Cách sửa là dời một dòng, sáu lần: chạy hook trước SignInAsync trên đường mật khẩu, OIDC, SAML, xác minh MFA và thiết lập MFA, và trước khi đúc token ở token endpoint. Thay đổi ở thư viện và thay đổi ở control plane được đưa vào cách nhau hai phút. Một lần từ chối giờ là một 403 gọn gàng không kèm Set-Cookie, và một bài test đầu cuối khẳng định rằng một lần đăng nhập bị từ chối không phát ra .AspNetCore.Cookies nào.
Cùng kiểu thứ tự đó xuất hiện ở ba chỗ lặng lẽ hơn. Các authorization code được đọc rồi mới xóa như hai thao tác riêng, nên hai lần đổi cùng một code diễn ra đồng thời đều có thể qua được bước đọc và đều nhận được token; kho grant giờ cung cấp TryConsumeAsync, một lệnh xóa có điều kiện chỉ trả về true cho bên gọi thực sự đã gỡ được dòng dữ liệu đó. Việc khôi phục thì upsert các thực thể trước khi xác minh bất cứ điều gì: trường FileHashes được ghi trong tài liệu là sẽ được xác minh khi khôi phục trong khi nó chưa bao giờ được điền vào hay được kiểm tra, điều này biến một bản backup bị can thiệp thành một công cụ ghi thẳng vào production. Và webhook Stripe thì hành động theo các sự kiện trước khi ghi nhận rằng nó đã thấy chúng, nên việc gửi lại theo kiểu ít nhất một lần có thể làm gói cước và trạng thái của một tenant nhảy qua nhảy lại hoặc gửi lại thư đòi nợ. Cả ba chỗ giờ đều kiểm tra trước.
Kiểm tra URL lúc lưu chẳng chứng minh được gì vào lúc gọi nó
Khách hàng đưa cho chúng tôi các URL: webhook auth, đích backup, callback lưu trữ. Từ chối một URL phân giải ra địa chỉ riêng tư ngay tại thời điểm nó được lưu là biện pháp bảo vệ hiển nhiên, và tự thân nó thì gần như vô giá trị, bởi vì DNS không phải là một lời hứa. Một tên miền phân giải ra địa chỉ công khai lúc bạn kiểm tra có thể phân giải ra 169.254.169.254 lúc bạn kết nối.
Vậy nên phần kiểm tra chịu lực không nằm trong handler cấu hình. Nó là một connect callback ngay trên chính HTTP handler. Mọi lệnh gọi đi tới một URL do khách hàng cung cấp đều phân giải host, từ chối loopback, RFC1918, unique-local và toàn bộ dải link-local 169.254.0.0/16 nơi dịch vụ metadata của đám mây trú ngụ, rồi mở socket tới đúng địa chỉ đã được kiểm tra đó thay vì tới tên miền, nên không còn lần phân giải thứ hai nào để mà rebind. Chuyển hướng tự động bị tắt, bởi vì một mã 302 là thêm một cơ hội nữa để chỉ tên một đích nội bộ. Phần kiểm tra lúc lưu vẫn được giữ lại như một thông báo lỗi cho khách hàng, vốn là tất cả những gì nó từng có ích.
Hai endpoint và một file backup mà chỉ có mạng lưới bảo vệ
/_internal/cluster/gossip được ánh xạ ở chế độ ẩn danh, và phần kiểm tra shared secret của nó tồn tại dưới dạng một dòng chú thích chứ không phải mã. /_internal/backchannel-logout được ánh xạ ẩn danh với antiforgery bị tắt trên listener công khai, nhận một body dạng {"SubjectId": "..."}, và thu hồi mọi grant mà subject đó đang nắm. Cả hai đều không có bất kỳ lớp bảo vệ nào trong tiến trình. Thứ duy nhất đứng trước chúng là cấu hình ingress, và với phát hiện về header chuyển tiếp ở trên thì dù sao mọi phòng thủ dựa trên IP cũng đều có thể bị giả mạo. Cả hai giờ đều yêu cầu một shared secret được so sánh trong thời gian hằng số, hoặc một địa chỉ nguồn nội bộ khi không có secret nào được cấu hình.
Các bản backup cũng mang đúng tính chất đó. Trên các host dùng nguồn khóa cục bộ, bảng khóa ký chứa scalar riêng tư EC, và việc backup thì tuần tự hóa nguyên văn mọi cột vào JSONL nén gzip ở dạng văn bản thuần. Bất kỳ ai đọc được một file backup đều có thể giả mạo token cho chính issuer mà nó đến từ đó. Bảng đó giờ bị loại trừ theo mặc định với một tùy chọn bật rõ ràng dành cho các đích đã mã hóa, và mọi file dữ liệu đều được băm SHA-256 vào manifest rồi xác minh trước khi việc khôi phục ghi dù chỉ một dòng.
Nơi một phép kiểm tra chạy quyết định phần lớn giá trị của nó
Không cái nào trong số này là một phép kiểm tra bị thiếu theo nghĩa chưa ai từng nghĩ tới nó. MFA đã được cài đặt và đã được test. Hook webhook chạy trên mọi đường. Phần kiểm tra SSRF đã tồn tại. Người dùng SCIM được giới hạn đúng cách trên mọi động từ, trong đúng cái file nằm cạnh cái file mà group thì không. Gần như trong mọi trường hợp, logic đều đã được viết ra, đúng đắn, và được đặt vào một chỗ mà nó không thể làm được việc của mình: trên một giá trị do bên gọi cung cấp, ở một bước mà bên gọi có thể bỏ qua, sau khi cái trạng thái nó lẽ ra phải ngăn chặn đã xảy ra, hoặc trên tài nguyên nằm ngay bên cạnh.
Vậy nên câu hỏi hữu ích ở mỗi endpoint không phải là "chúng ta có kiểm tra cái này không". Mà là ba câu khác. Request này thực sự chứng minh được điều gì, khác với điều nó khẳng định? Định danh này là thứ mà một bên gọi có thể gọi tên, hay là thứ chỉ đúng bên gọi mới nắm giữ được? Và có điều gì không thể đảo ngược xảy ra trước khi câu trả lời tới nơi không? Một bản đánh giá viết ra, làm từng mục một với một mức nghiêm trọng cho mọi phát hiện, chủ yếu là một công cụ để ép ba câu hỏi đó đi qua đoạn mã mà bạn đang chắc chắn. Của chúng tôi thì chắc chắn. Nó là mười sáu mục chắc chắn, cộng thêm tám thứ lẽ ra đã trở thành sự cố của ai đó.
Không có gì trong số đó chạm tới được bởi một khách hàng, bởi vì khi ấy chưa có khách hàng nào. Đó chính là lập luận cho việc làm chuyện này trước khi ra mắt thay vì sau báo cáo đầu tiên: cũng danh sách ấy, viết ra sáu tháng sau, sẽ là một dòng thời gian công bố lỗ hổng thay vì công việc của một buổi tối. Nếu bạn muốn phiên bản hiện tại của các câu trả lời, trang bảo mật của chúng tôi trình bày cách các tenant được cô lập, những gì được mã hóa khi lưu trữ, và một bản backup dữ liệu của bạn thực sự chứa những gì.