Có một nhầm lẫn về CORS mà tôi gặp đi gặp lại: người ta tưởng bật CORS là thêm một lớp bảo mật, kiểu "cho an toàn hơn". Hãy nghĩ về nó như cái chốt cửa. Same-origin policy là cái chốt đã cài sẵn — mặc định, không trang nào ở tên miền khác được đọc dữ liệu của bạn. CORS không phải cái chốt thứ hai; nó là hành động mở chốt cho một vài vị khách bạn tin. Cấu hình CORS sai vì thế không làm bạn kém an toàn đi một chút — nó tháo hẳn cái chốt mà trình duyệt vốn cài giúp bạn. Bài này tôi đo, bằng trình duyệt thật, xem mỗi cách "mở chốt" cho ai đọc đượ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. 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

Ba dòng, ba bài học

Cấu hình "phản chiếu Origin" là lỗ hổng nghiêm trọng — và nó trông giống hệt một danh sách trắng động. Đây là dòng đáng sợ. Cách này lấy header Origin của request rồi phản chiếu nguyên si 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ả là bất kỳ trang nào — kể cả evil — cũng 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à lý do nó sống dai là vì nó hoạt động trong test: mọi origin đều đọc được, gồm cả origin thật của bạn, nên nó chỉ lộ ra là lỗ khi có ai đó nghĩ tới việc lạm dụng.

Access-Control-Allow-Origin: * không cho phép credentials — và đó là một bảo vệ do chính chuẩn cài sẵ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ế mà cấu hình "phản chiếu Origin" mới ra đờ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 tin-cay.com và app thật đọc (kèm credentials), nhưng evil bị chặn vì không có tên trong danh sách. Khác biệt với phản chiếu Origin tinh vi đến mức nguy hiểm trong mã — cả hai đều đặt Allow-Origin bằng một giá trị động — nhưng khác biệt về 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à câu này cực kỳ dễ đọ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ẽ. 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 kịp 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".

Điều này dẫn thẳng tới cái gốc mà tôi muốn bạn giữ: 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. Hệ quả là: CORS không chặn request tới server (nên không thay được CSRF); cấu hình CORS lỏng không làm server bị tấn công trực tiếp mà làm người dùng của bạn bị đọc trộm dữ liệu bởi một trang độc họ tình cờ mở; và vì thế cấu hình sai nguy hiểm một cách âm thầm — server không thấy gì bất thường, chỉ có người dùng bị hại.

Ra thực tế thì bốn điều gọn gàng. Dùng danh sách trắng origin tường minh, đừng phản chiếu Origin — nếu bạn thấy mã lấy request.headers['Origin'] rồi đặt thẳng vào Allow-Origin, đó là lỗ, phải kiểm origin so với một danh sách trước. Chỉ bật Allow-Credentials: true khi 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 nên để * 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. Và đừ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ỗ nằm chờ sẵn khi lên production.

Mẫu số chung

Cái lõi của lỗ này không phải CORS mà là một phản xạ nguy hiểm: lấy một giá trị do phía-đối-diện gửi lên rồi dùng chính nó làm quyết định 'có tin không'. Phản chiếu Origin là hỏi kẻ tấn công "anh có phải người tôi tin không" rồi tin luôn câu trả lời của anh ta. Cùng phản xạ đó tái diễn ở mọi tầng.

Trong thế giới Java, đây đúng là cặp CorsConfiguration.setAllowedOrigins(...) (danh sách tĩnh, đúng) đối lại việc tự viết một filter đọc header Origin rồi echo lại. Spring còn cài sẵn cái bẫy này thành lời cảnh báo: gọi setAllowedOrigins(List.of("*")) cùng setAllowCredentials(true) sẽ ném lỗi lúc khởi động, buộc bạn chuyển sang setAllowedOriginPatterns — chính là chuẩn CORS "cấm * với credentials" được dựng vào framework.

Trong Go, thư viện github.com/rs/cors cho bạn AllowedOrigins (danh sách, an toàn) và AllowOriginFunc (tự quyết định) — và cái bẫy kinh điển là viết AllowOriginFunc: func(o string) bool { return true } hay tệ hơn là echo o vào header; cùng bài học "đừng để đầu vào tự phê duyệt chính nó".

Còn ở cơ sở dữ liệu, cùng phản xạ ấy mang tên khác: GRANT ... TO PUBLIC (cho tất cả, như phản chiếu mọi origin) đối lại GRANT cho từng vai cụ thể; hay nguy hiểm hơn, một hàm đọc current_setting('app.tenant_id') do client tự đặt để quyết định thấy dòng nào — nếu client đặt được giá trị đó thì nó tự cấp quyền cho mình. Row-level security chỉ an toàn khi tiêu chí phân quyền đến từ danh tính máy chủ xác thực, không đến từ giá trị client gửi lên.

Mẫu số chung: một quyết định phân quyền chỉ đáng tin khi tiêu chí của nó đến từ phía bạn kiểm soát — hằng danh sách trắng, danh tính đã xác thực — chứ không đến từ giá trị mà phía-cần-kiểm-tra tự khai. Vì thế câu hỏi đúng khi nhìn bất kỳ luật "cho phép nếu..." nào không phải "luật có chặt không" mà là "cái nếu này lấy dữ liệu từ đâu — từ tôi, hay từ chính kẻ đang xin phép" — và nếu là vế sau, bạn không có luật nào cả.

Muốn tự soi trong 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ó kèm Access-Control-Allow-Credentials: true, hãy kiểm tiếp: server có phản chiếu bất kỳ origin nào không? Gửi một request với header Origin: http://evil.com rồi xem phản hồi. Nếu Allow-Origin trả về đúng http://evil.com, bạn đang 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 mình.