CORS (Cross-Origin Resource Sharing) là một trong những cơ chế bị hiểu sai nhiều nhất của web. Nhiều người nghĩ CORS là một lớp bảo mật thêm vào — bật nó để "an toàn hơn". Sự thật ngược lại: CORS là cơ chế để nới lỏng một quy tắc bảo mật mặc định (same-origin policy), cho phép một số trang khác origin đọc dữ liệu của bạn. Cấu hình sai không làm bạn kém an toàn một chút — nó mở toang cánh cửa mà same-origin policy vốn khoá. Bài này đo, bằng trình duyệt thật, cấu hình CORS nào cho phép ai đọc gì.
Toàn bộ chạy trên hai origin tự dựng trong container, tự dọn. Không nhắm vào hệ thống của ai.
Cách đo
Same-origin policy mặc định cấm một trang ở origin A đọc phản hồi từ origin B. CORS là cách origin B nói "tôi cho phép A đọc". Tôi dựng một API có dữ liệu bí mật (số dư, số thẻ), cấu hình CORS bốn cách, rồi từ một trang ở origin evil dùng Chromium thật fetch API đó có kèm credentials — mô phỏng một trang độc cố đọc dữ liệu đã đăng nhập của nạn nhân.
Số đo
Ba lần chạy, kết quả giống hệt:
| Cấu hình CORS của API | Trang evil đọc được? |
|---|---|
| Không header CORS | chặn |
Access-Control-Allow-Origin: * |
chặn (vì có credentials) |
Phản chiếu Origin + Allow-Credentials: true |
ĐỌC ĐƯỢC {"so_du":1000000,"the":"4111-XXXX"} |
| Danh sách trắng origin | chặn |
Điều đáng nhớ
Cấu hình "phản chiếu Origin" là lỗ hổng nghiêm trọng — và nó trông giống một danh sách trắng động. Đây là dòng đáng sợ. Cách cấu hình này lấy header Origin của request và phản chiếu lại vào Access-Control-Allow-Origin, kèm Allow-Credentials: true. Ý định thường là "cho phép mọi origin, mà vẫn dùng được credentials". Nhưng kết quả: bất kỳ trang nào — kể cả evil — đều thấy origin của nó được phản chiếu lại, nên trình duyệt cho phép đọc. Trong phép đo, trang evil đọc trọn {"so_du":1000000,"the":"4111-XXXX"} — dữ liệu bí mật của người dùng đã đăng nhập. Đây là một trong những lỗi CORS phổ biến nhất, vì nó hoạt động trong test (mọi origin đều đọc được, gồm cả origin thật của bạn) và chỉ lộ ra là lỗ khi ai đó nghĩ tới việc lạm dụng nó.
Access-Control-Allow-Origin: * không cho phép credentials — đó là một bảo vệ của chuẩn. Dòng thứ hai: dù đặt *, trang evil vẫn không đọc được, vì request của tôi kèm credentials:include. Chuẩn CORS cấm kết hợp * với credentials — một trang chỉ đọc được dữ liệu có credentials khi origin của nó được nêu tường minh, không phải qua *. Đây là một quyết định thiết kế thông minh: * an toàn cho dữ liệu công khai (không credentials), nhưng không thể vô tình dùng để lộ dữ liệu riêng. Chính vì thế cấu hình "phản chiếu Origin" mới tồn tại — người ta cần credentials nên không dùng được *, và phản chiếu origin là cách sai để lách.
Danh sách trắng origin là cách đúng — chỉ nêu tường minh những origin bạn tin. Dòng cuối: danh sách trắng cho phép tin-cay.com và app thật đọc (kèm credentials), nhưng evil bị chặn vì không có trong danh sách. Khác biệt với phản chiếu Origin rất tinh tế trong mã — cả hai đều đặt Allow-Origin bằng giá trị động — nhưng khác biệt bảo mật là tuyệt đối: phản chiếu cho mọi origin, danh sách trắng chỉ cho origin đã kiểm.
Một hiểu lầm cần gỡ: "Failed to fetch" không phải "request bị chặn"
Trong phép đo, các cấu hình an toàn trả về CHAN: Failed to fetch — và điều này dễ bị đọc sai. CORS không ngăn request tới server; nó ngăn trình duyệt trao phản hồi cho JavaScript. Với cấu hình "không CORS", request của trang evil vẫn tới server, server vẫn xử lý và trả về dữ liệu — nhưng trình duyệt, thấy không có header CORS cho phép, từ chối đưa phản hồi đó cho mã JavaScript của evil. Từ góc nhìn của evil, đó là "Failed to fetch"; từ góc nhìn server, request thành công bình thường.
Sự phân biệt này quan trọng vì hai lý do. Một: nó giải thích vì sao CORS không thay được CSRF — nếu request là một hành động đổi trạng thái (chuyển tiền), thiệt hại đã xảy ra ở server trước khi trình duyệt chặn phản hồi. Hai: nó cho thấy CORS bảo vệ đúng một thứ — khả năng đọc dữ liệu trả về — chứ không phải toàn bộ giao dịch. Đọc kỹ "Failed to fetch" nghĩa là "không đọc được phản hồi", không phải "request không xảy ra".
Vì sao
Điểm gốc là hiểu CORS bảo vệ ai: CORS bảo vệ người dùng, không bảo vệ server. Same-origin policy tồn tại để một trang độc không đọc được dữ liệu mà trình duyệt của bạn có quyền truy cập (vì bạn đã đăng nhập). CORS là cách server chủ động nới quy tắc đó cho các origin nó tin. Nên:
- CORS không chặn request tới server — request vẫn tới nơi (đó là lý do CORS không thay CSRF được, bài CSRF). Nó chỉ chặn trình duyệt trả phản hồi về cho JavaScript của trang khác origin.
- Cấu hình CORS lỏng không làm server bị tấn công trực tiếp — nó làm người dùng của bạn bị đọc trộm dữ liệu bởi một trang độc mà họ tình cờ mở.
- Vì thế cấu hình sai nguy hiểm âm thầm: server không thấy gì bất thường, chỉ người dùng bị hại.
Nghĩa là gì trong thực tế
- Dùng danh sách trắng origin tường minh, không phản chiếu Origin. Nếu bạn thấy mã lấy
request.headers['Origin']và đặt thẳng vàoAllow-Origin, đó là lỗ — kiểm origin so với một danh sách trước. - Chỉ bật
Allow-Credentials: truekhi thật cần, và khi bật thì bắt buộc dùng danh sách trắng chính xác (không*, không phản chiếu). Dữ liệu công khai không cần credentials — để*là đủ và an toàn. - Nhớ CORS không thay CSRF. CORS kiểm việc đọc phản hồi cross-origin; CSRF là việc gửi request cross-origin. Một endpoint đổi trạng thái cần cả hai lớp (bài CSRF).
- Đừng bật CORS "cho tiện lúc dev" rồi quên.
Allow-Origin: *trên một API có credentials, hoặc phản chiếu Origin, là lỗ chờ sẵn khi lên production.
Chỗ tôi không kết luận được
Tôi đo request GET đơn giản. CORS còn có luồng preflight (OPTIONS) cho các request "không đơn giản" (method khác, header tuỳ biến) — server phải trả đúng header preflight thì request thật mới chạy. Tôi không đo luồng preflight, vốn có sắc thái riêng về caching và các header được phép.
Và tôi đo bằng Chromium. Các trình duyệt tuân CORS chặt chẽ, nhưng CORS chỉ là cơ chế phía trình duyệt — một client không phải trình duyệt (curl, một server khác) bỏ qua CORS hoàn toàn. Nên CORS không bảo vệ dữ liệu khỏi mọi client, chỉ khỏi JavaScript chạy trong trình duyệt của nạn nhân. Đó đúng là mối đe doạ nó sinh ra để chặn, nhưng đừng nhầm nó là kiểm soát truy cập tổng quát.
Thử ba mươi giây
Mở công cụ nhà phát triển, tab Network, gọi một API của bạn và xem header phản hồi. Tìm Access-Control-Allow-Origin.
Nếu giá trị của nó bằng đúng origin trang đang gọi (không phải *, không phải một origin cố định), và có Access-Control-Allow-Credentials: true — kiểm tiếp: server có phản chiếu bất kỳ origin nào không? Gửi request với header Origin: http://evil.com và xem phản hồi. Nếu Allow-Origin trả về http://evil.com, bạn có một lỗ CORS cho phép bất kỳ trang nào đọc dữ liệu có credentials của người dùng bạn.