Đừng nghĩ các thuộc tính cookie như những nấc của một thanh trượt "an toàn ít → an toàn nhiều", bật càng nhiều càng chắc. Hãy nghĩ về chúng như những ổ khoá khác loại trên các cửa khác nhau của cùng một ngôi nhà: một cái khoá cửa sổ, một cái khoá cửa chính, một cái chốt cổng. Khoá cửa sổ chẳng giúp gì nếu kẻ trộm vào bằng cửa chính — không phải vì nó dở, mà vì nó canh một cánh cửa khác. Cookie phiên là chìa khoá vào tài khoản người dùng: lấy được nó là vào được mà không cần mật khẩu. Bài này tôi đo trực tiếp từng thuộc tính — HttpOnly, SameSite, Path — canh đúng cánh cửa nào, bằng một trình duyệt Chromium thật thay vì đọc tài liệu.
Toàn bộ chạy trên container tự dựng, tự dọn; không nhắm vào hệ thống của ai.
Cách đo
Máy chủ đặt bốn cookie cùng lúc, mỗi cái mang thuộc tính khác nhau: thuong (không thuộc tính đặc biệt), httponly (có HttpOnly), cathuoc (có HttpOnly và SameSite=Strict), và path_han (giới hạn Path=/khu-vuc-khac). Rồi tôi đo ba câu hỏi bằng trình duyệt thật: JavaScript (document.cookie) đọc được cookie nào, máy chủ đọc được cookie nào, và cookie giới hạn Path xuất hiện ở đâu. Ba lần chạy, kết quả giống hệt:
| Ai đọc / ở đâu | Cookie thấy được |
|---|---|
JavaScript (document.cookie) |
chỉ thuong |
Máy chủ, path gốc / |
thuong, httponly, cathuoc |
Máy chủ, path /khu-vuc-khac/ |
cả bốn, gồm path_han |
Một câu về vì sao phải đo bằng trình duyệt thật, vì nó quyết định độ tin của cả bài: một phép thử phía máy chủ không thể phân biệt HttpOnly với cookie thường — máy chủ nhận cả hai y hệt trong header Cookie, bất kể thuộc tính. Khác biệt của HttpOnly chỉ tồn tại bên trong trình duyệt, quyết định document.cookie có trả về cookie đó không. Muốn đo nó, bắt buộc phải có một trình duyệt thật thực thi JavaScript và một document.cookie thật để đọc. Đây là cùng nguyên tắc suốt sê-ri: đo thứ trình duyệt làm, không đo thứ tài liệu nói — và ở đây document.cookie trả về đúng ['thuong'], không hơn, lặp lại ba lần.
Ba điều bảng này nói
HttpOnly giấu cookie khỏi JavaScript, nhưng máy chủ luôn đọc được. Đọc dòng đầu và dòng hai cùng nhau: JavaScript chỉ thấy thuong, hai cookie HttpOnly vô hình với document.cookie, nhưng máy chủ vẫn nhận đủ chúng trong header Cookie của mọi yêu cầu. Nghĩa là HttpOnly không phải "cookie bí mật" — nó là lá chắn riêng chống đúng một thứ: XSS ăn cắp cookie. Nếu kẻ tấn công chèn được JavaScript vào trang bạn, đoạn document.cookie của nó không lấy được cookie HttpOnly. Cookie phiên vì thế bắt buộc phải có HttpOnly, để một lỗ XSS không tự động thành chiếm phiên. Mặt trái nằm ở dòng đầu: cookie thuong không thuộc tính hiện ra trong document.cookie, nên chỉ một lỗ XSS là kẻ tấn công đọc và gửi nó đi — đừng bao giờ để dữ liệu nhạy cảm trong cookie mà JavaScript đọc được; cookie cần JavaScript (như lựa chọn giao diện sáng/tối) thì không đặt HttpOnly, nhưng chúng cũng không được chứa gì bí mật.
Path giới hạn nơi cookie được gửi, nhưng không phải ranh giới bảo mật. path_han chỉ xuất hiện khi máy chủ nhận yêu cầu tới /khu-vuc-khac/, vắng mặt ở path gốc. Nghe như một cơ chế cô lập — cookie của khu quản trị chỉ gửi tới khu quản trị. Nhưng đây là hiểu lầm nguy hiểm: Path là công cụ tổ chức, không phải bảo mật. Mọi path trên cùng một tên miền chia sẻ một không gian bảo mật; một trang ở / bị XSS có thể mở iframe hoặc gửi yêu cầu tới /khu-vuc-khac/, và cookie path_han sẽ tự đính kèm. Path ngăn cookie đi nhầm chỗ, không ngăn kẻ tấn công tới đúng chỗ. Nó còn có mặt trái vận hành: path_han vắng ở path gốc là nguồn lỗi khó lần — một cookie đặt Path=/gio-hang sẽ "biến mất" khi người dùng ở trang chủ, và lập trình viên đi tìm một lỗi phiên không tồn tại. Vì Path không đem lại bảo mật gì, cái giá gỡ lỗi này gần như luôn không đáng — nên khuyến nghị mặc định là Path=/.
Mỗi thuộc tính thật ra kiểm soát một trục khác nhau của "cookie này đi đâu, ai chạm được". HttpOnly kiểm soát ai trong trình duyệt đọc được — chặn JavaScript, cho phép chính trình duyệt gửi kèm; trục "script hay không script". SameSite kiểm soát ngữ cảnh nào cookie được gửi — same-site hay cross-site; trục "trang nào khởi phát yêu cầu". Secure kiểm soát giao thức nào — chỉ HTTPS, không bao giờ HTTP; trục "kênh có mã hoá không" (tôi đo trên HTTP nội bộ nên không kiểm được trục này). Path và Domain kiểm soát phạm vi URL — trục "địa chỉ", và đây là trục không bảo mật. Cách đọc đúng: các thuộc tính này không cộng dồn thành "an toàn hơn" mà mỗi cái đóng một cửa cụ thể — HttpOnly đóng cửa XSS-ăn-cookie, SameSite đóng cửa CSRF, Secure đóng cửa nghe lén trên đường truyền; biết cửa nào mới đặt đúng thuộc tính. Bảng đo minh hoạ trục thứ nhất và thứ tư trong cùng một lần chạy: cathuoc mang cả HttpOnly lẫn SameSite=Strict, vắng khỏi document.cookie (do HttpOnly) trong khi vẫn tới máy chủ ở path gốc (vì phép đo là same-site nên SameSite không chặn) — hai thuộc tính trên cùng một cookie, mỗi cái làm việc của mình mà không cản việc cái kia.
Ra thực tế thì cấu hình đúng cho một cookie phiên gần như luôn là cả bốn:
Set-Cookie: sid=...; HttpOnly; Secure; SameSite=Lax; Path=/
HttpOnly để XSS không đọc được phiên; Secure để phiên không rò qua HTTP (kể cả một request nhầm sang http://); SameSite=Lax để chặn CSRF — hoặc Strict nếu không cần link từ ngoài vào; Path=/ vì Path không thêm bảo mật, đặt hẹp hơn chỉ gây lỗi khó hiểu khi cookie "biến mất" ở path khác. Và một quy tắc bao trùm: đừng dựa vào Path/Domain để cô lập quyền — muốn tách khu quản trị khỏi khu người dùng, hãy dùng phiên riêng và kiểm quyền phía máy chủ, không phải một thuộc tính cookie.
Mẫu số chung
Cái bẫy sâu nhất của bài này không phải chuyện cookie mà là chuyện lẫn tổ chức với bảo mật — tưởng thứ chỉ để sắp xếp cho gọn là một bức tường ngăn. Path là ví dụ kinh điển, và cùng nhầm lẫn ấy tái diễn ở mọi tầng.
Trong thế giới Java, bốn thuộc tính này chính là server.servlet.session.cookie.http-only, .secure, .same-site và .path trong Spring Boot (hay Cookie.setHttpOnly/setSecure thủ công). Nhưng cái bẫy "tổ chức tưởng là bảo mật" hiện ở chỗ khác: package private/public của Java là ranh giới biên dịch, không phải bảo mật runtime — reflection xuyên qua nó dễ dàng, nên đừng bao giờ đặt một bí mật là private rồi tin nó được bảo vệ. Muốn bảo vệ thật thì cần SecurityManager/module boundary, đúng như cookie phiên cần HttpOnly chứ không phải Path.
Trong Go, http.Cookie{HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, Path: "/"} là bản dịch trực tiếp. Và cùng bài học lặp lại: một chữ thường đầu tên hàm trong Go (unexported) là ranh giới gói, tiện cho tổ chức mã, nhưng không phải cơ chế bảo mật — nó không giấu được gì khỏi kẻ đã chạy trong tiến trình của bạn.
Còn ở cơ sở dữ liệu, đây là nhầm lẫn tôi thấy nhiều nhất: schema (namespace) của PostgreSQL/MySQL là công cụ tổ chức — gom bảng cho gọn — chứ tự nó không cô lập quyền; một user vẫn đọc được bảng ở schema khác nếu không có REVOKE/GRANT đúng. Ranh giới bảo mật thật là hệ thống quyền (role, GRANT, row-level security), y như Path chỉ sắp xếp còn HttpOnly/xác thực phía máy chủ mới bảo vệ.
Mẫu số chung: một cơ chế "sắp xếp cho gọn" (path, package, schema, namespace) gần như không bao giờ là một ranh giới bảo mật — ranh giới bảo mật phải là thứ được thiết kế để chống lại kẻ cố tình vượt qua, không phải thứ chỉ tình cờ khiến việc đi nhầm chỗ khó hơn. Vì thế câu hỏi đúng khi dựa vào bất kỳ ranh giới nào không phải "nó có tách các thứ ra không" mà là "nó có đứng vững khi một kẻ cố tình tìm cách xuyên qua không" — nếu câu trả lời là không, bạn có một công cụ tổ chức, không phải một cái khoá.
Muốn tự soi trong ba mươi giây, mở công cụ nhà phát triển, tab Application → Cookies, chọn cookie phiên của một site bạn dùng và nhìn ba cột HttpOnly, Secure, SameSite. Nếu cột HttpOnly không có dấu tích, cookie phiên ấy đọc được bằng JavaScript — nghĩa là một lỗ XSS bất kỳ trên site đó là một vụ chiếm phiên, không cần thêm điều kiện gì. Đó là ô quan trọng nhất trong ba ô, và cũng là ô dễ kiểm nhất.