Skip to content
Go back

HTTP và CORS cơ bản

Làm backend thì ngày nào cũng làm việc với HTTP, nhưng ít ai trả lời được câu hỏi “vì sao HTTP lại thiết kế như vậy?”. Bài này đi từ vài nguyên lý cốt lõi, rồi dùng chúng để giải thích message, method, status code và cache — vì phần lớn những thứ này là hệ quả trực tiếp của các nguyên lý đó. Phần cuối nói về CORS. CORS không nằm trong chuẩn HTTP, mà là cơ chế riêng của browser, hoạt động bằng các HTTP header.

Các nguyên lý cốt lõi của HTTP

HTTP (Hypertext Transfer Protocol) là giao thức để client và server trao đổi dữ liệu trên web. Toàn bộ HTTP xoay quanh sáu nguyên lý dưới đây.

Client và server

HTTP chỉ có hai vai: client là bên gửi yêu cầu, server là bên xử lý và trả kết quả.

Client ở đây không chỉ là browser. App mobile, curl, Postman, hay một service backend gọi sang service khác — tất cả đều là client. Điểm cần nhớ: server không biết và cũng không cần biết bên gọi là ai. Nó chỉ dựa vào những gì có trong request để xử lý.

Request và response

Mọi cuộc trao đổi trong HTTP đều theo một khuôn mẫu duy nhất: client gửi một request, server trả về đúng một response.

Từ khuôn mẫu này có hai hệ quả bạn nên nắm:

  • Client luôn là bên gửi request. Server chỉ gửi dữ liệu khi đang trả lời một request.
  • Request nào cũng có response. Kể cả khi xử lý thất bại, server vẫn trả về một response để báo lỗi.

Vậy nếu server muốn chủ động đẩy dữ liệu xuống client — ví dụ thông báo realtime — thì sao? HTTP thông thường không làm được, và đó là lúc chúng ta cần tới WebSocket hoặc các kỹ thuật khác, được nói trong ghi chép về WebSocket.

Stateless

HTTP là giao thức stateless: server không nhớ gì về các request trước đó. Mỗi request phải tự mang theo đủ thông tin để server xử lý.

Nghe có vẻ là hạn chế, nhưng đây lại là một trong những quyết định thiết kế quan trọng nhất của HTTP. Hãy xem hai hệ quả của nó:

  • Instance nào cũng xử lý được request nào. Vì server không giữ trạng thái giữa các request, bạn có thể chạy mười instance giống hệt nhau phía sau load balancer. Request rơi vào instance nào cũng xử lý được. Đây chính là nền tảng để scale ngang.
  • Client phải tự gửi lại ngữ cảnh ở mỗi request. Ví dụ, server không “nhớ” bạn đã đăng nhập. Mỗi request, client phải gửi kèm cookie hoặc token để chứng minh mình là ai. Ghi chép về authentication sẽ đi sâu vào phần này.

Lưu ý: stateless không có nghĩa là hệ thống không lưu gì. Database vẫn lưu user, đơn hàng như bình thường. Stateless chỉ nói rằng bản thân giao thức không giữ trạng thái giữa các request.

Self-descriptive message

Nguyên lý này là hệ quả trực tiếp của stateless. Server không nhớ gì, nên mỗi message phải tự nói lên tất cả về chính nó. Ngoài dữ liệu, message còn kèm thông tin mô tả dữ liệu đó: là JSON hay HTML, dài bao nhiêu byte, ngôn ngữ gì, có được cache không. Message như vậy được gọi là self-descriptive.

Toàn bộ phần thông tin mô tả này nằm trong header — chúng ta sẽ quay lại ở phần cấu trúc message.

Intermediary

Trong thực tế, request hiếm khi đi thẳng từ client tới server. Trên đường đi, nó thường qua một vài intermediary như proxy, load balancer, API gateway hay CDN.

Sơ đồ dưới đây là đường đi của một lần gọi: request đi từ client, qua intermediary, tới server; response đi ngược lại theo cùng con đường.

flowchart LR
    C["Client<br/>browser, app, service"] -->|request| I["Intermediary<br/>proxy, load balancer, CDN"]
    I -->|request| S["Server"]
    S -.->|response| I
    I -.->|response| C
    classDef main fill:#dbeafe,stroke:#1e40af,color:#1e3a8a
    classDef infra fill:#f4f4f5,stroke:#52525b,color:#27272a
    class C,S main
    class I infra

Câu hỏi thú vị là: các intermediary này không biết gì về logic ứng dụng của bạn, vậy làm sao chúng làm đúng việc? Câu trả lời nằm ở nguyên lý self-descriptive. Mọi thông tin chúng cần đều nằm sẵn trong message. CDN thấy response có header Cache-Control: max-age=600 là biết được phép cache trong 10 phút, không cần hỏi lại server. Load balancer nhìn header Host là biết chuyển request tới service nào.

Muốn vậy thì mọi bên trên đường đi phải hiểu method, status code và header theo cùng một nghĩa. Đó là lý do HTTP chuẩn hoá ý nghĩa của chúng trong các RFC.

Extensibility

HTTP ra đời từ đầu những năm 90, nhưng tới giờ vẫn liên tục có thêm header mới, status code mới. Điều đáng chú ý là việc thêm này không làm hỏng các phần mềm đã chạy từ trước. Nói cách khác, HTTP được thiết kế để extensible và backward compatible ngay từ đầu.

Bí quyết rất đơn giản: phần mềm gặp header mà nó không hiểu thì cứ bỏ qua, không báo lỗi. Nhờ quy ước đó, CORS — một cơ chế ra đời rất lâu sau HTTP — có thể được thêm vào chỉ bằng vài header mới, mà không cần sửa lại giao thức.

Các nguyên lý liên quan với nhau thế nào

Nhìn lại cả sáu nguyên lý, bạn sẽ thấy chúng không đứng riêng lẻ mà dựa vào nhau:

Nguyên lýHiểu đơn giảnNhờ đó mà
Client–serverHai vai rõ ràngMột server phục vụ được nhiều loại client
Request–responseMỗi request có đúng một responseTrao đổi đơn giản, dễ đoán
StatelessRequest tự mang đủ thông tinScale bằng cách chạy thêm instance
Self-descriptiveMessage tự mô tả nội dung của nóCache, proxy làm việc mà không cần hỏi app
IntermediaryMọi bên hiểu method, code, header như nhauCó load balancer, CDN, gateway ở giữa
ExtensibilityGặp phần không hiểu thì bỏ quaThêm tính năng mới mà không phá cái cũ

HTTP nằm ở đâu trong mạng

Một điểm hay bị nhầm: HTTP không tự truyền dữ liệu qua mạng. Nó chỉ quy định message có nghĩa là gì. Việc đưa từng byte từ máy này sang máy kia là việc của các layer bên dưới.

LayerPhụ tráchVí dụ
ApplicationÝ nghĩa của messageHTTP
SecurityMã hoá dữ liệu, xác minh serverTLS
TransportTruyền dữ liệu đầy đủ, đúng thứ tự giữa hai máyTCP, QUIC
NetworkĐịnh tuyến packet giữa các máyIP

Chính sự tách biệt này giúp ý nghĩa của HTTP chỉ cần định nghĩa một lần, trong RFC 9110. HTTP/1.1, HTTP/2 và HTTP/3 dùng chung method, status code và header; chúng chỉ khác nhau ở cách truyền message qua mạng. Phần cuối bài sẽ so sánh ba phiên bản này.

Cấu trúc của một HTTP message

Quay lại nguyên lý self-descriptive: để message tự mô tả được, nó cần một cấu trúc cố định mà bên nào cũng đọc được. Request hay response đều gồm bốn phần:

  1. Start line: dòng đầu tiên, cho biết đây là request gì hoặc kết quả ra sao.
  2. Header: các thông tin mô tả message.
  3. Một dòng trống: báo hiệu phần header đã hết.
  4. Body: dữ liệu chính, có thể không có.

Request

POST /orders HTTP/1.1
Host: api.shop.test
Content-Type: application/json
Accept: application/json
Content-Length: 25

{"sku": "A-42", "qty": 1}

Start line của request gọi là request line, gồm ba phần:

PhầnTrong ví dụÝ nghĩa
MethodPOSTClient muốn làm gì
Target/ordersLàm trên resource nào
VersionHTTP/1.1Phần còn lại của message theo định dạng nào

Bạn để ý header Host: nó cho biết request gửi tới domain nào. HTTP/1.1 bắt buộc phải có header này, vì một server thường chạy nhiều domain trên cùng một địa chỉ IP.

Response

HTTP/1.1 201 Created
Content-Type: application/json
Location: /orders/9001
Content-Length: 33

{"id": 9001, "status": "pending"}

Start line của response gọi là status line, gồm phiên bản, status code ba chữ số và một reason phrase ngắn. Code của bạn nên xử lý dựa vào status code; reason phrase chỉ để con người đọc log cho dễ hiểu.

Header là các cặp tên–giá trị, tên không phân biệt hoa thường. Có rất nhiều header, nhưng chia theo mục đích thì chỉ có vài nhóm:

NhómTrả lời câu hỏiVí dụ
Thông tin requestAi gọi, gọi tới đâu?Host, User-Agent, Authorization
Content negotiationClient nhận được những dạng dữ liệu nào?Accept, Accept-Language, Accept-Encoding
RepresentationBody có định dạng gì, dài bao nhiêu?Content-Type, Content-Length, Content-Encoding
CacheCó được cache không, trong bao lâu?Cache-Control, ETag, If-None-Match, Vary
Thông tin responseServer muốn cho biết thêm điều gì?Location, Retry-After, Allow

Body và độ dài của body

Một câu hỏi tưởng đơn giản: bên nhận làm sao biết body kết thúc ở đâu? HTTP/1.1 có hai cách:

  • Content-Length: ghi rõ body dài bao nhiêu byte. Bên nhận đọc đủ số byte đó là xong.
  • Transfer-Encoding: chunked: gửi body thành từng chunk, mỗi chunk ghi kích thước riêng, kết thúc bằng một chunk rỗng. Cách này dùng khi server bắt đầu gửi mà chưa biết tổng kích thước — ví dụ khi stream một file export lớn.

Với HTTP/2 và HTTP/3, message được chia thành các frame ngay từ tầng giao thức, nên không cần hai header này để xác định điểm kết thúc của body.

Content negotiation

Cùng một resource có thể được trả về ở nhiều dạng: JSON hay XML, tiếng Anh hay tiếng Việt, có nén hay không. Mỗi dạng như vậy gọi là một representation. Còn content negotiation là cách client và server “thương lượng” xem sẽ trả dạng nào.

Cách làm rất trực quan: client nói mình nhận được những dạng nào, server chọn một dạng rồi ghi rõ đã chọn gì trong response.

Client gửiServer trả lời kèm
Accept: application/jsonContent-Type: application/json
Accept-Language: vi, en;q=0.8Content-Language: vi
Accept-Encoding: gzip, brContent-Encoding: br

Giá trị q là mức ưu tiên, từ 0 đến 1. Ở ví dụ trên, client muốn tiếng Việt trước, nếu không có thì tiếng Anh cũng được.

Method

Chữ đầu tiên của request line là method. Method cho server biết client muốn làm gì với resource.

Hai tính chất: safe và idempotent

Khi học về method, đừng chỉ nhớ tên. Điều quan trọng hơn là hai tính chất mà chuẩn HTTP định nghĩa cho chúng:

  • Safe: method chỉ đọc, không yêu cầu server thay đổi dữ liệu gì.
  • Idempotent: gọi một lần hay gọi nhiều lần thì kết quả cuối cùng trên server vẫn như nhau.

Dễ thấy method safe thì chắc chắn idempotent: đã không thay đổi gì thì gọi bao nhiêu lần cũng vậy.

Các method thường gặp

MethodDùng đểSafeIdempotent
GETĐọc một resourcecócó
HEADNhư GET nhưng chỉ lấy headercócó
OPTIONSHỏi server hỗ trợ những gìcócó
POSTTạo resource mới hoặc chạy một tác vụkhôngkhông
PUTGhi đè toàn bộ resource ở một URL cụ thểkhôngcó
PATCHSửa một phần resourcekhôngkhông đảm bảo
DELETEXoá resourcekhôngcó

PATCH được định nghĩa trong RFC 5789; các method còn lại nằm trong RFC 9110.

Lưu ý: idempotent nói về kết quả trên server, không nói về response. Gọi DELETE hai lần, lần đầu bạn nhận 204, lần sau nhận 404 — response khác nhau, nhưng kết quả trên server thì như nhau: resource đã bị xoá.

Vì sao hai tính chất này quan trọng

Hai tính chất này không phải lý thuyết suông. Chúng cho mọi bên trên đường đi biết request nào được phép tự gửi lại.

  • Với method safe, browser có thể tải trước trang, cache có thể lưu response, mà không lo làm thay đổi dữ liệu.
  • Với method idempotent, client có thể retry thoải mái. Hãy hình dung kết nối bị đứt khi response chưa kịp về: client không biết server đã xử lý hay chưa. Nếu method là idempotent, cứ gửi lại là xong.
  • POST thì không có tính chất nào trong hai. Retry một POST có thể tạo ra bản ghi thứ hai. Để retry an toàn, chúng ta dùng idempotency key: client gửi kèm một giá trị duy nhất, server dựa vào đó để nhận ra request bị gửi lặp. Ghi chép về thiết kế REST API sẽ nói chi tiết.

Status code

Mỗi request nhận về một response, và status code là cách nhanh nhất để biết kết quả. Đây là số có ba chữ số; chữ số đầu tiên cho biết nó thuộc nhóm nào.

Năm nhóm

NhómÝ nghĩaClient nên làm gì
1xxThông tinTiếp tục chờ
2xxThành côngKhông cần làm gì thêm
3xxChuyển hướngGọi tới địa chỉ khác
4xxLỗi phía clientSửa request rồi mới gửi lại
5xxLỗi phía serverChờ server khắc phục; có thể retry

Trong năm nhóm, ranh giới giữa 4xx và 5xx là thứ bạn cần hiểu kỹ nhất, vì nó là ranh giới trách nhiệm. 4xx nghĩa là lỗi nằm ở request — gửi lại y nguyên cũng vô ích. 5xx nghĩa là lỗi nằm ở server — lát nữa thử lại có thể thành công. Logic retry và hệ thống monitoring đều dựa vào ranh giới này. Trả nhầm 500 cho một request sai dữ liệu, bạn có thể vô tình kích hoạt cảnh báo cho cả team on-call.

Các status code thường gặp

CodeTênÝ nghĩa
200OKThành công, có body
201CreatedĐã tạo resource mới; URL của nó nằm trong Location
202AcceptedĐã nhận request, sẽ xử lý sau
204No ContentThành công, không có body
301Moved PermanentlyResource đã chuyển hẳn sang URL mới
302FoundResource tạm thời ở URL khác
304Not ModifiedBản cache của client vẫn dùng được
307Temporary RedirectNhư 302, nhưng phải giữ nguyên method và body
308Permanent RedirectNhư 301, nhưng phải giữ nguyên method và body
400Bad RequestRequest sai định dạng
401UnauthorizedChưa authenticate
403ForbiddenĐã authenticate nhưng không có quyền
404Not FoundKhông tìm thấy resource
405Method Not AllowedResource không hỗ trợ method này
409ConflictRequest xung đột với trạng thái hiện tại của resource
415Unsupported Media TypeServer không nhận định dạng body này
422Unprocessable ContentĐúng định dạng nhưng dữ liệu không hợp lệ
429Too Many RequestsGửi quá nhiều request, bị rate limit
500Internal Server ErrorServer gặp lỗi không lường trước
502Bad GatewayProxy nhận response không hợp lệ từ server phía sau
503Service UnavailableServer tạm thời không phục vụ được
504Gateway TimeoutProxy chờ server phía sau quá lâu

Lưu ý: đây là chỗ rất nhiều người nhầm. Tên là “Unauthorized” nhưng 401 thực ra nghĩa là chưa authenticate — server chưa biết bạn là ai. Còn 403 nghĩa là server biết bạn là ai, nhưng bạn không có quyền làm việc này.

Cache

Bây giờ bạn sẽ thấy rõ vì sao hai nguyên lý self-descriptive và intermediary quan trọng: nhờ chúng mà HTTP có cơ chế cache sẵn. Cache lưu lại response để dùng cho các request sau, server không phải xử lý lại từ đầu.

Muốn cache đúng, phải trả lời được hai câu hỏi. Thứ nhất, response này dùng lại được trong bao lâu? Thứ hai, khi đã hết hạn, dữ liệu thật sự có thay đổi chưa? HTTP trả lời hai câu hỏi này bằng hai cơ chế tương ứng: freshness và validation.

Freshness

Server cho biết response được dùng lại trong bao lâu qua header Cache-Control.

DirectiveÝ nghĩa
max-age=600Dùng lại được trong 600 giây
no-cacheĐược lưu, nhưng phải hỏi lại server trước mỗi lần dùng
no-storeKhông được lưu
privateChỉ browser của user được lưu, CDN thì không
publicCache dùng chung như CDN cũng được lưu

Lưu ý: cái tên no-cache rất dễ gây hiểu lầm. Nó không có nghĩa là “không cache”, mà là “được lưu, nhưng phải hỏi lại server trước khi dùng”. Muốn cấm lưu hẳn, bạn phải dùng no-store.

Validation

Khi bản cache đã hết hạn (stale), cache không nhất thiết phải tải lại toàn bộ. Nó chỉ cần hỏi server một câu: “dữ liệu có đổi không?”

Để hỏi được, server gắn cho mỗi phiên bản của response một ETag — một mã ngắn đại diện cho đúng nội dung đó. Lần sau, cache gửi lại mã này trong header If-None-Match. Nếu dữ liệu chưa đổi, server chỉ trả 304 Not Modified không kèm body, và cache dùng tiếp bản cũ. Với một response lớn, cách này tiết kiệm băng thông đáng kể.

Response thay đổi theo request

Có một chi tiết dễ bị bỏ sót. Vì có content negotiation, cùng một URL có thể trả về nhiều representation khác nhau. Cache tuyệt đối không được đưa bản tiếng Việt cho người đang hỏi bản tiếng Anh. Header Vary giải quyết chuyện này: nó cho cache biết response phụ thuộc vào request header nào, ví dụ Vary: Accept-Language. Khi đó cache lưu riêng một bản cho mỗi giá trị của header đó.

Cache phía server là một chủ đề lớn, được nói riêng trong ghi chép về caching.

Các phiên bản HTTP

Như đã nói, ý nghĩa của HTTP giống nhau ở mọi phiên bản. Cái thay đổi qua từng phiên bản là cách truyền message qua mạng — cụ thể là cách nhiều request chia sẻ một kết nối.

HTTP/1.1

HTTP/1.1 gửi message dạng text qua TCP. Một kết nối có thể dùng lại cho nhiều request, nhưng mỗi lúc chỉ xử lý một request. Request sau phải đợi response của request trước về xong.

Vấn đề này có tên là head-of-line blocking: chỉ cần một response chậm, mọi request xếp hàng phía sau đều phải chờ theo. Browser khắc phục bằng cách mở song song nhiều kết nối tới cùng một server — một giải pháp chữa cháy hơn là giải quyết tận gốc.

HTTP/2

HTTP/2 giải quyết vấn đề trên bằng cách chia message thành các binary frame. Frame của nhiều request được gửi xen kẽ trên cùng một kết nối, mỗi request thuộc một stream riêng. Nhờ vậy, nhiều request chạy song song trên một kết nối duy nhất. HTTP/2 còn nén header để giảm dung lượng truyền đi.

Tuy nhiên, head-of-line blocking vẫn chưa biến mất hoàn toàn — nó chỉ chuyển xuống tầng dưới. Mọi stream vẫn dùng chung một kết nối TCP, mà TCP bắt buộc dữ liệu tới đúng thứ tự. Chỉ cần mất một packet, tất cả các stream đều phải đợi packet đó được gửi lại.

HTTP/3

HTTP/3 giải quyết nốt vấn đề này bằng cách bỏ TCP, chuyển sang QUIC — một transport protocol xây trên UDP. QUIC xử lý từng stream độc lập, nên mất một packet chỉ làm chậm đúng stream chứa packet đó. Ngoài ra, QUIC gộp bước bắt tay kết nối với bước bắt tay TLS, nên kết nối mới được thiết lập nhanh hơn.

So sánh

HTTP/1.1HTTP/2HTTP/3
TransportTCPTCPQUIC trên UDP
Định dạngTextBinary frameBinary frame
Request trên một kết nốiLần lượt từng cáiSong songSong song
Head-of-line blockingỞ tầng HTTPỞ tầng TCPChỉ trong một stream
SpecificationRFC 9112RFC 9113RFC 9114

Origin và same-origin policy

Mọi thứ chúng ta nói từ đầu bài tới giờ đều đúng với bất kỳ HTTP client nào. Bản thân HTTP không có quy tắc nào về việc ai được đọc response. Nhưng browser thì cần một quy tắc như vậy. Lý do là browser chạy code của rất nhiều website trong cùng một nơi, và đang giữ cookie đăng nhập của bạn cho tất cả các website đó. Để hiểu quy tắc này, trước hết cần biết origin là gì.

Origin

Origin của một URL gồm ba phần: scheme, host và port (RFC 6454). Hai URL chỉ cùng origin khi cả ba phần đều giống nhau.

URLCùng origin với https://shop.test?Lý do
https://shop.test/cartcóPath không tính vào origin
http://shop.testkhôngKhác scheme
https://api.shop.testkhôngKhác host
https://shop.test:8443khôngKhác port

Một hệ quả mà developer nào cũng từng gặp: frontend chạy ở localhost:5173 và API chạy ở localhost:8080 là hai origin khác nhau, dù cùng một máy.

Same-origin policy

Same-origin policy là quy tắc của browser: JavaScript trên một trang được gửi request sang origin khác, nhưng không được đọc response, trừ khi origin kia cho phép.

Vì sao lại chặn việc đọc? Câu trả lời nằm ở cookie. Khi trang A gửi request sang origin B, browser tự động gắn cookie của bạn ở B vào request. Nếu JavaScript của trang A đọc được response, thì một website bất kỳ có thể đọc dữ liệu cá nhân của bạn trên mọi website khác mà bạn đang đăng nhập — email, tài khoản ngân hàng, mọi thứ.

Còn việc gửi request sang origin khác thì xưa nay vẫn được phép, vì web cần nó để hoạt động: thẻ <img>, form, script tải từ CDN đều là request cross-origin. Thứ duy nhất bị chặn là việc đọc response.

CORS

Same-origin policy bảo vệ người dùng rất tốt, nhưng cũng rất chặt. Trong thực tế, frontend ở origin này thường xuyên cần đọc dữ liệu từ API ở origin khác. CORS ra đời để mở ngoại lệ cho những trường hợp như vậy — một cách có kiểm soát.

CORS (Cross-Origin Resource Sharing) là cơ chế, định nghĩa trong Fetch Standard, cho phép server báo với browser những origin nào được đọc response của nó. Và bạn sẽ thấy nguyên lý extensibility ở đầu bài phát huy tác dụng: CORS hoạt động hoàn toàn bằng vài HTTP header mới.

Sơ đồ dưới đây tóm tắt toàn bộ cách browser xử lý một request cross-origin. Các mục tiếp theo sẽ giải thích từng bước.

flowchart TD
    A["Trang gọi sang origin khác"] --> B{"Simple request?"}
    B -->|có| C["Gửi request kèm Origin"]
    B -->|không| D["Gửi preflight OPTIONS"]
    D --> E{"Server cho phép<br/>method và header?"}
    E -->|không| F["Dừng: không gửi<br/>request thật"]
    E -->|có| C
    C --> G{"Response cho phép<br/>origin này?"}
    G -->|có| H["JavaScript đọc được response"]
    G -->|không| X["JavaScript không<br/>đọc được response"]
    classDef main fill:#dbeafe,stroke:#1e40af,color:#1e3a8a
    classDef infra fill:#f4f4f5,stroke:#52525b,color:#27272a
    classDef ok fill:#dcfce7,stroke:#166534,color:#14532d
    classDef bad fill:#fee2e2,stroke:#991b1b,color:#7f1d1d
    class B,E,G main
    class A,C,D infra
    class H ok
    class F,X bad

CORS là gì và không phải là gì

Trước khi đi vào chi tiết, có một điều cần nói rõ: CORS chỉ có thể nới lỏng same-origin policy, chứ không siết thêm được. Server không trả header CORS nào thì browser áp dụng mức chặn mặc định — tức là chặt nhất.

Từ đây rút ra hai điều mà rất nhiều developer hiểu sai:

  • CORS bảo vệ user, không bảo vệ server. Nó ngăn website lạ đọc dữ liệu mà browser lấy về bằng cookie của user.
  • Chỉ browser mới áp dụng CORS. curl, Postman hay một service backend khác đều bỏ qua CORS hoàn toàn. Vì vậy, đừng bao giờ coi CORS là lớp bảo mật cho API. Bảo vệ API là việc của authentication và authorization.

Simple request

Có những request cross-origin mà form HTML đã gửi được từ trước khi CORS ra đời. Vì web vốn đã cho phép chúng, browser gửi thẳng những request này mà không hỏi trước. Chúng được gọi là simple request.

Một request là simple khi thoả cả ba điều kiện:

  • Method là GET, HEAD hoặc POST.
  • Chỉ dùng các header trong danh sách safelisted, như Accept hay Content-Language.
  • Nếu có body thì Content-Type là application/x-www-form-urlencoded, multipart/form-data hoặc text/plain.

Với simple request, browser thêm header Origin, gửi đi, rồi mới kiểm tra header CORS trong response. Bạn cần để ý điểm này: nếu không được phép, JavaScript không đọc được response — nhưng server thì đã nhận và xử lý request rồi.

Preflight request

Các request còn lại có thể làm những việc mà form HTML không bao giờ làm được, như gửi JSON hay gửi header tự đặt. Với những request này, browser cẩn thận hơn: nó hỏi server trước khi gửi. Request hỏi trước đó gọi là preflight.

Preflight là một request OPTIONS, báo cho server biết request thật sẽ dùng method và header nào. Server trả lời có cho phép hay không. Chỉ khi được cho phép, browser mới gửi request thật.

Trong thực tế, phần lớn API call đều cần preflight, vì chỉ cần một trong các điều sau:

  • Body là JSON (Content-Type: application/json).
  • Có header Authorization hoặc header tự đặt khác.
  • Method là PUT, PATCH hoặc DELETE.
sequenceDiagram
    participant B as Browser (trang trên app.shop.test)
    participant S as Server (api.shop.test)
    B->>S: OPTIONS /orders/9001<br/>Origin: https://app.shop.test<br/>Access-Control-Request-Method: PUT<br/>Access-Control-Request-Headers: content-type
    S-->>B: 204 No Content<br/>Access-Control-Allow-Origin: https://app.shop.test<br/>Access-Control-Allow-Methods: PUT<br/>Access-Control-Allow-Headers: content-type
    Note over B: Server đã cho phép
    B->>S: PUT /orders/9001
    S-->>B: 200 OK<br/>Access-Control-Allow-Origin: https://app.shop.test

Nếu preflight bị từ chối, request thật sẽ không bao giờ được gửi đi.

Simple requestPreflighted request
Khi nàoRequest mà form HTML gửi đượcCác trường hợp còn lại
Số lần gọi server12 (còn 1 nếu kết quả preflight đã được cache)
Nếu không được phépServer vẫn xử lý, JavaScript không đọc đượcRequest thật không được gửi

Các header CORS

HeaderAi gửiÝ nghĩa
OriginBrowserOrigin của trang đang gọi
Access-Control-Request-MethodBrowser, trong preflightMethod của request thật
Access-Control-Request-HeadersBrowser, trong preflightCác header ngoài danh sách safelisted mà request thật dùng
Access-Control-Allow-OriginServerOrigin được phép đọc response, hoặc * cho mọi origin
Access-Control-Allow-MethodsServer, trong preflightCác method được phép
Access-Control-Allow-HeadersServer, trong preflightCác request header được phép
Access-Control-Allow-CredentialsServerCho phép đọc response khi request có kèm cookie
Access-Control-Expose-HeadersServerResponse header nào JavaScript được đọc thêm
Access-Control-Max-AgeServer, trong preflightBrowser được cache kết quả preflight bao nhiêu giây

Credential

Trong CORS, credential là những thứ browser tự động gắn vào request: cookie, HTTP authentication và client certificate. Cần phân biệt rõ: token mà JavaScript tự đặt vào header Authorization thì không tính là credential theo nghĩa này.

Vì credential mở đường tới dữ liệu riêng của user, các quy tắc cho nó chặt hơn hẳn:

  • Trang phải chủ động bật, ví dụ fetch(url, { credentials: "include" }).
  • Server phải trả kèm Access-Control-Allow-Credentials: true.
  • Access-Control-Allow-Origin phải ghi đúng một origin cụ thể, không được dùng *.

Một câu hỏi hay gặp là “dùng * có sao không?”. Câu trả lời phụ thuộc vào cách API xác định user:

API xác định user bằngDùng Access-Control-Allow-Origin: * được không?
Không cần (dữ liệu public)Được
Token trong header AuthorizationĐược
CookieKhông — phải ghi đúng origin

Cho phép nhiều origin

Access-Control-Allow-Origin chỉ chứa được một origin. Vậy nếu có nhiều frontend thì sao? Server giữ một danh sách origin được phép. Mỗi request tới, server so header Origin với danh sách, khớp thì trả lại đúng origin đó.

Lúc này response thay đổi theo header Origin của request. Nhớ lại phần cache: response thay đổi theo request header thì phải khai báo Vary: Origin. Nếu quên, CDN có thể lưu response dành cho origin A rồi trả nhầm cho origin B — một lỗi rất khó debug.

Cấu hình CORS trên server

Đến đây, các quy tắc ở trên có thể chuyển thẳng thành code. Ví dụ dưới dùng Node.js thuần, không framework, để bạn thấy rõ từng header.

import { createServer } from "node:http";

const ALLOWED_ORIGINS = new Set([
  "https://app.shop.test",
  "http://localhost:5173",
]);

createServer((req, res) => {
  const origin = req.headers.origin;

  // The answer depends on the Origin header, so caches must keep them apart.
  res.setHeader("Vary", "Origin");

  if (origin && ALLOWED_ORIGINS.has(origin)) {
    res.setHeader("Access-Control-Allow-Origin", origin);
    // Needed only if the API identifies users with cookies.
    res.setHeader("Access-Control-Allow-Credentials", "true");
  }

  // Preflight: answer before any authentication, without a body.
  if (
    req.method === "OPTIONS" &&
    req.headers["access-control-request-method"]
  ) {
    res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE");
    res.setHeader(
      "Access-Control-Allow-Headers",
      "Content-Type, Authorization"
    );
    res.setHeader("Access-Control-Max-Age", "600");
    res.writeHead(204).end();
    return;
  }

  // Authentication and the real request handling go here.
  res.writeHead(200, { "Content-Type": "application/json" });
  res.end(JSON.stringify({ ok: true }));
}).listen(8080);

Mỗi đoạn trong code ứng với một quy tắc đã học:

Trong codeVì sao
Vary: OriginResponse thay đổi theo request header thì phải khai báo cho cache
Danh sách origin so khớp chính xácKhi dùng cookie, Allow-Origin phải là một origin cụ thể
Origin lạ thì không trả header CORSKhông có header, browser giữ nguyên mức chặn mặc định
Xử lý preflight trước bước authenticationBrowser không bao giờ gửi cookie hay token kèm preflight
Access-Control-Max-AgeBrowser không phải preflight lại cho mỗi request

Đọc một lỗi CORS

Khi gặp lỗi CORS, hãy nhớ rằng lỗi do browser báo, không phải server. Mở tab Network của browser, xem request tương ứng, thường bạn sẽ thấy ngay nguyên nhân:

Tab Network cho thấyNguyên nhân
Request OPTIONS bị 401 hoặc 403Preflight bị chặn ở bước authentication, trong khi nó không mang cookie hay token
OPTIONS thành công nhưng request thật không được gửiMethod hoặc header chưa có trong Allow-Methods / Allow-Headers
Response không có Access-Control-Allow-OriginOrigin chưa được cho phép, hoặc route này không trả header CORS
Access-Control-Allow-Origin: * trong khi dùng cookieDùng cookie thì phải ghi đúng origin

Tóm tắt

  • HTTP dựa trên sáu nguyên lý: client–server, request–response, stateless, self-descriptive message, intermediary và extensibility. Các phần khác của giao thức đều xuất phát từ đây.
  • Message gồm start line, header, một dòng trống và body (có thể không có). Header mô tả message để mọi bên trên đường đi đều hiểu được.
  • Với method, điều cần nhớ là hai tính chất: safe nghĩa là chỉ đọc, idempotent nghĩa là gọi lại bao nhiêu lần cũng cho cùng kết quả trên server.
  • Status code cho client biết bước tiếp theo: 4xx là sửa request, 5xx là lỗi server và có thể retry sau.
  • Cache dựa vào freshness (Cache-Control) và validation (ETag, 304). Vary giúp cache không trả nhầm representation.
  • HTTP/1.1, HTTP/2 và HTTP/3 có cùng ý nghĩa, chỉ khác cách truyền message qua mạng.
  • Same-origin policy là quy tắc của browser: chặn việc đọc response cross-origin, không chặn việc gửi request.
  • CORS cho phép server mở ngoại lệ cho những origin được chọn. Nó bảo vệ user chứ không bảo vệ server, và chỉ browser mới áp dụng nó.

Tài liệu tham khảo

Next Post