Mọi cơ chế kiểm soát theo IP chúng tôi từng ship đều bị bypass bằng một header
Bộ đếm khóa tài khoản của chúng tôi giới hạn số lần login thất bại ở mức năm. Rate limiter của chúng tôi bóp các client có hành vi lạm dụng. Cả hai đều khóa theo địa chỉ IP của client, thứ hiển nhiên nhất để làm khóa. Cả hai đều bị bypass hoàn toàn, và cách bypass là một HTTP header duy nhất mà bạn tự đặt được.
Lý do là thế này. Khi app của bạn chạy sau một reverse proxy, và app của chúng tôi chạy sau nginx trên Kubernetes, kết nối TCP mà app nhìn thấy không đến từ người dùng. Nó đến từ proxy. Mọi request đều mang cùng một địa chỉ socket peer: ingress. Vì vậy IP thật của client phải được chở trong một header, X-Forwarded-For, do proxy đặt và app đọc. ASP.NET Core có middleware cho đúng việc này: nó đọc X-Forwarded-For và ghi đè remote IP của kết nối cho khớp, để phần còn lại của code, bao gồm cả rate limiter, nhìn thấy client thật.
Vấn đề là X-Forwarded-For chỉ là một header. Ai cũng gửi được. Nếu app tin nó vô điều kiện, và app của chúng tôi đã tin như vậy, tin nó từ bất kỳ nguồn nào, thì giá trị đó nằm trong tay kẻ tấn công. Nên cuộc tấn công chẳng có gì tinh vi, nó gói gọn trong một dòng:
curl -H "X-Forwarded-For: 10.0.0.1" https://.../login
curl -H "X-Forwarded-For: 10.0.0.2" https://.../login
curl -H "X-Forwarded-For: 10.0.0.3" https://.../login
Đổi header ở mỗi request và app tưởng mỗi lần thử đến từ một client mới toanh. Bộ đếm khóa tài khoản, khóa theo IP đó, không bao giờ chạm mốc năm với bất kỳ giá trị đơn lẻ nào. Bucket của rate limiter thì lần nào cũng mới tinh. Bạn có thể brute force mật khẩu cả ngày, và mọi lớp phòng thủ theo IP của chúng tôi vui vẻ đếm đến một. Một cơ chế kiểm soát khóa theo giá trị do kẻ tấn công đặt thì không phải là cơ chế kiểm soát.
Cách sửa nghe có vẻ tầm thường: chỉ tin X-Forwarded-For từ proxy của chính bạn. Về nguyên tắc thì tầm thường thật, nhưng thực tế lại lắt nhắt, và Kubernetes là nơi sự lắt nhắt đó trú ngụ.
Bạn không thể đơn giản là ngừng đọc header. Nếu làm vậy, mọi request trông như đến từ ingress, thế là toàn bộ client của bạn dùng chung một IP và các cơ chế kiểm soát theo IP hỏng theo chiều ngược lại: một kẻ lạm dụng kích hoạt khóa tài khoản cho tất cả mọi người. Bạn phải đọc X-Forwarded-For, nhưng chỉ tin nó khi request thực sự đến từ một proxy bạn tin tưởng.
Middleware forwarded-headers của ASP.NET tin theo nguồn: một danh sách IP proxy đã biết, hoặc các dải mạng proxy đã biết cho dưới dạng CIDR. Nước đi ngây thơ là ghim IP của pod ingress. Trên AKS IP đó không ổn định. Pod ingress bị reschedule và scale lại, địa chỉ của chúng đổi theo, nên một IP ghim cứng sẽ lỗi thời và hoặc là làm hỏng traffic hợp lệ, hoặc nếu bạn để lại một fallback cũ, mở lại đúng cái lỗ hổng đó. Thứ ổn định là cái subnet mà các pod lấy địa chỉ từ đó. Vậy nên bạn tin CIDR của node subnet, không phải bất kỳ địa chỉ đơn lẻ nào. Mọi hop có quyền hợp lệ đặt header đều nằm trong dải đó, và không gì bên ngoài nó được tin.
Rồi đến chuyện đếm. Middleware duyệt danh sách X-Forwarded-For từ phải sang, bóc từng hop được tin một, và thứ còn lại sau các hop được tin sẽ được coi là client. Duyệt quá một hop là bạn bước ra khỏi proxy của mình và lọt vào phần header do kẻ tấn công viết. Nên số hop bạn bóc phải bằng đúng số proxy thực sự append vào header, chính xác từng con số. Trong đường đi của chúng tôi có một: nginx. Load balancer của Azure đứng trước nó là layer 4. Nó forward TCP, không parse HTTP, và không thêm entry X-Forwarded-For nào. Vậy forward limit đúng là một. Không phải giá trị mặc định, không phải "vài cái cho chắc". Một, vì có đúng một proxy chạm vào header đó.
Cặp đôi đó, tin node subnet và bóc đúng một hop, là toàn bộ cách sửa. IP client mà middleware đưa cho bạn giờ là giá trị nginx viết ra từ socket thật, còn các entry kẻ tấn công chèn vào nằm bên trái nó, bị bỏ qua, vì chúng tôi dừng duyệt trước khi chạm tới chúng.
Phần mang đi được không phải là các con số, mà là chuyện chúng là những con số về topology cụ thể của bạn. X-Forwarded-For không phải dữ liệu, nó là sự ủy thác lòng tin: đọc nó tức là tuyên bố "tôi tin bất kỳ ai đã đặt cái này". Lòng tin đó phải được ghim vào đúng các hop trong đường đi request của bạn, không rộng hơn và không hẹp hơn. Tin quá rộng thì bị spoof. Tin quá hẹp thì bạn gộp toàn bộ client thành cái proxy. Và con số đó gắn chặt với hạ tầng của bạn, nên cái ngày bạn đặt một CDN trước mọi thứ, bạn đã thêm một hop, và cái forward limit đúng của ngày hôm qua giờ thiếu mất một. Cách sửa không phải là một hằng số thần kỳ. Nó là: đếm số proxy trong đường đi của bạn, tin đúng những cái đó và chỉ những cái đó, và đếm lại mỗi khi đường đi thay đổi.
Đếm hop proxy cho đúng là việc chẳng hào nhoáng gì, dễ làm sai, và nó quyết định rate limit của bạn có nghĩa lý gì hay không. Authagonal làm phần sổ sách đó thay bạn, để bộ đếm khóa tài khoản của bạn đếm client chứ không đếm một cái header.