CVE-2024-34351 của Next.js: Header Host là dữ liệu người dùng

Vì sao một lệnh redirect tương đối trong Server Actions của Next.js thành SSRF, và vì sao không được dựng URL nội bộ từ header Host.

CVE-2024-34351 của Next.js: Header Host là dữ liệu người dùng
Trong bài viết này

CVE-2024-34351 là một lỗi giả mạo yêu cầu phía máy chủ (SSRF) trong Server Actions của Next.js. Lỗi đã được vá từ năm 2024, nhưng sai lầm gốc rễ của nó vẫn còn rất phổ biến: dựng URL cho một request nội bộ từ header Host của request đi vào. Nâng cấp Next.js chỉ là một phần của bài học. Phần đáng nhớ lâu hơn là: Host là dữ liệu người dùng, và kẻ tấn công có thể tác động tới mọi URL suy ra từ nó.

Chuyện gì đã thực sự xảy ra

Bản tin bảo mật GHSA-fr5h-rqp8-mj6g trên GitHub áp dụng cho Next.js từ 13.4.0 đến trước 14.1.1. Lỗ hổng được công bố ngày 9/5/2024. Nó được chấm 7,5 điểm (High) theo CVSS 3.1 và được phân loại là CWE-918. Adam Kues và Shubham Shah của Assetnote là những người báo cáo lỗi, sau đó họ công bố một bài phân tích chi tiết.

Để khai thác được, cần hội đủ ba điều kiện cùng lúc:

  • Ứng dụng tự lưu trữ (self-hosted), ví dụ chạy server Node.js hoặc container phía sau một reverse proxy thông thường.
  • Ứng dụng dùng Server Actions ('use server').
  • Một Server Action gọi redirect() với đường dẫn tương đối như /dashboard.

Điều kiện thứ ba là chỗ thiết kế trở nên đáng chú ý. Sau khi action chuyển hướng, phía client muốn có sẵn payload React Server Components của trang đích. Nhờ vậy giao diện có thể chuyển trang mà không phải tải lại toàn bộ. Để tạo payload đó, server tự gửi một HTTP request tới route đích. Và để biến /dashboard thành một URL tuyệt đối, nó lấy host từ chính request vừa nhận được.

Nếu header đó mang một giá trị bất thường, request "nội bộ" của server sẽ đi tới chiếc máy do người gửi request lựa chọn. Request này vẫn xuất phát từ máy chủ ứng dụng và mang theo quyền truy cập mạng của máy chủ đó. Đó chính là SSRF. Bài nghiên cứu gốc mô tả cách phản hồi có thể bị dẫn đi xa hơn. Bài viết này chỉ đứng ở góc độ phòng thủ và tập trung vào lỗi thiết kế.

Theo các thông tin đã công bố, lỗi này chỉ xảy ra khi ứng dụng tự lưu trữ. Ứng dụng chạy trên Vercel không rơi vào kịch bản này.

Redirect không phải là tự gọi chính mình

Dây mạng được đi gọn gàng vào một bộ chuyển mạch nhôm xước trong phòng máy chủ yên tĩnh có tường bê tông thô

Lỗi này trộn lẫn hai khái niệm trông có vẻ giống nhau:

  • Origin công khai là tên miền người dùng nhìn thấy. Bạn cần nó cho link tuyệt đối, canonical URL và email.
  • Địa chỉ nội bộ là cách tiến trình gọi tới chính nó hoặc tới một service bên cạnh, ví dụ 127.0.0.1:3000.

Header Host không trả lời đáng tin cậy cho câu hỏi nào trong hai câu trên. Nó chỉ cho biết client tự nhận là mình muốn gì. Reverse proxy có thể chuyển tiếp header này nguyên vẹn. X-Forwarded-Host cũng là một header client có thể kiểm soát, trừ khi proxy của bạn chủ động ghi đè nó.

Dưới đây là anti-pattern viết bằng TypeScript thuần. Nó xuất hiện trong middleware tự viết, helper SSR, và các tiện ích kiểu "fetch API route của chính mình" ở dự án Express, Nuxt hay Next.js:

// Anti-pattern: đích đến phụ thuộc vào dữ liệu từ client
const base = `http://${req.headers.host}`;
const res = await fetch(new URL(path, base));

Cách sửa khá nhàm chán, và đó chính là điều tốt:

// Lời gọi nội bộ luôn đi tới một địa chỉ cố định do bạn kiểm soát
const INTERNAL_ORIGIN =
  process.env.INTERNAL_ORIGIN ?? `http://127.0.0.1:${process.env.PORT ?? 3000}`;

const res = await fetch(new URL(path, INTERNAL_ORIGIN));

Tốt hơn nữa, hãy bỏ hẳn bước đi qua mạng và gọi trực tiếp hàm xử lý bên dưới. Một HTTP request gửi tới chính mình thường chỉ là một lời gọi hàm chậm hơn và rủi ro hơn.

Với link công khai, hãy đọc origin chuẩn từ cấu hình, chẳng hạn biến môi trường PUBLIC_ORIGIN, thay vì lấy lại giá trị từ request. Thói quen này cũng bảo vệ email đặt lại mật khẩu khỏi kiểu tấn công Host header poisoning, một lớp lỗi khác nhưng có họ hàng rất gần.

Nâng cấp, rồi nhìn xa hơn con số phiên bản

Bản vá nằm trong Next.js 14.1.1, được đưa vào qua pull request #62561. Thay đổi cốt lõi là đích đến của request nội bộ không còn phụ thuộc vào giá trị Host do client gửi lên.

Hãy coi 14.1.1 là mức sàn, không phải mục tiêu. Next.js đã công bố thêm các bản tin bảo mật khác kể từ đó. Các nhóm tự lưu trữ nên bám theo một nhánh phát hành còn được hỗ trợ, thay vì dừng lại ở phiên bản đầu tiên vá lỗ hổng cụ thể này.

Nâng cấp sẽ đóng con đường lỗi bên trong framework. Nó không sửa cùng pattern đó trong code của chính bạn. Vì vậy các lớp phòng thủ tiếp theo mới là phần quan trọng.

Phòng thủ nhiều lớp cho ứng dụng tự lưu trữ

Từ chối host lạ ngay tại proxy

Với Nginx, một server block default_server đóng vai trò hứng tất cả có thể loại bỏ những request có Host không khớp với bất kỳ server_name nào đã cấu hình. Server block thật sau đó đặt các header chuyển tiếp một cách tường minh:

server {
    listen 80 default_server;
    server_name _;
    return 444;  # đóng kết nối với host không xác định
}

server {
    listen 80;
    server_name app.example.com;

    location / {
        proxy_set_header Host app.example.com;
        proxy_set_header X-Forwarded-Host app.example.com;
        proxy_pass http://127.0.0.1:3000;
    }
}

Nếu đứng một mình, proxy_set_header Host $host; chỉ đơn giản là chuyển tiếp thứ client đã gửi. Lớp bảo vệ thực sự đến từ việc đẩy mọi tên miền lạ về block hứng tất cả và cố định các giá trị bạn chuyển tiếp xuống backend. Hãy áp dụng cách xử lý tương tự cho listener TLS.

Học theo mặc định của Django

Django vốn đã coi Host là dữ liệu không đáng tin. request.get_host() kiểm tra giá trị này với ALLOWED_HOSTS và từ chối các giá trị không khớp bằng phản hồi 400. USE_X_FORWARDED_HOST cũng bị tắt theo mặc định. Các stack JavaScript hiếm khi có sẵn hành vi này. Vì vậy bạn nên tự thêm một bản tương đương: một middleware nhỏ so sánh Host với danh sách cho phép và trả về 400 khi không khớp.

Giới hạn những gì server có thể chạm tới

Mức thiệt hại của SSRF tỷ lệ thuận với những gì server có thể truy cập. Quy tắc egress cho container nên chặn các dải mạng quản trị nội bộ và mọi đích đến mà ứng dụng không bao giờ cần. Trên AWS EC2, khi bắt buộc dùng IMDSv2, việc truy cập metadata đòi hỏi một session token lấy bằng request PUT. Một request GET bị giả mạo đơn giản không thể cung cấp token đó. Đặt hop limit bằng 1 còn giữ cho token không tới được các workload cách thêm một bước mạng, chẳng hạn container chạy mạng bridge.

Một lượt rà soát nhanh

Hãy tìm trong codebase những chỗ đọc host của request:

grep -rnE "headers\.host|x-forwarded-host|get\('host'\)" src/

Với mỗi kết quả tìm được, hãy tự hỏi hai câu. Giá trị này có quyết định nơi một request đi ra ngoài sẽ tới không? Nó có đi vào một lệnh redirect, một đường link hay một email không? Nếu một trong hai câu trả lời là có, hãy thay nó bằng giá trị cấu hình hoặc bằng một bước kiểm tra theo danh sách cho phép.

Lỗi của framework đã được vá. Còn pattern gây ra lỗi đó thì chỉ có thể sửa bằng thói quen review của chính bạn. Hãy nghi ngờ header Host y như cách bạn vẫn nghi ngờ một query string.

Nguồn tham khảo

  1. tenable.com
  2. nvd.nist.gov
  3. cybersecurity-help.cz
  4. mindmix.blog
  5. mallory.ai
  6. slcyber.io

Có hỗ trợ AI · PKN duyệt lại

0

Phản hồi

Đang tải bình luận…