บทความ

OPTIONS request คืออะไร CORS preflight ทำงานยังไง

เบราว์เซอร์ส่ง OPTIONS ไปถาม server ก่อนเมื่อหน้าเว็บจะยิง API ข้ามโดเมนด้วยคำขอที่ไม่ธรรมดา ถ้า server ตอบอนุญาต คำขอจริงถึงจะตามไป ใน Laravel มี HandleCors คอยตอบให้อยู่แล้ว

เผยแพร่
เวลาอ่าน
5 นาที
แท็ก
#HTTP#Laravel

OPTIONS เป็น HTTP method ตัวหนึ่ง

OPTIONS อยู่ในมาตรฐาน HTTP มานานแล้ว ใช้ถาม server ว่า URL นี้รองรับอะไรบ้าง (MDN) server ตอบกลับมาโดยไม่ต้องทำงานจริง ไม่อ่าน ไม่แก้ข้อมูล

curl -i -X OPTIONS https://example.com/api/products
HTTP/1.1 204 No Content
Allow: GET, HEAD, POST, OPTIONS

ในงานจริงแทบไม่มีใครเขียนโค้ดยิง OPTIONS เอง ที่เราเห็นบ่อย ๆ ใน DevTools หรือใน access log ของ nginx เกือบทั้งหมดเป็นเบราว์เซอร์ส่งเองตอนทำ CORS preflight

เบราว์เซอร์ไม่ให้หน้าเว็บอ่านข้อมูลข้ามโดเมนเอง

เริ่มจากกฎของเบราว์เซอร์ที่ชื่อ same-origin policy โค้ด JavaScript บนหน้าเว็บอ่าน response ได้เฉพาะจาก origin เดียวกับหน้าเว็บ origin คือสามอย่างรวมกัน: scheme, โดเมน และ port

หน้าเว็บอยู่ที่ ยิงไปที่ origin เดียวกัน?
https://shop.test https://shop.test/api/products ใช่
https://shop.test https://api.shop.test/products ไม่ โดเมนต่าง
http://localhost:5173 http://localhost:8000/api ไม่ port ต่าง
http://shop.test https://shop.test/api ไม่ scheme ต่าง

กฎนี้กันไม่ให้เว็บแปลกหน้าที่เราเผลอเปิด แอบยิงไปอ่านข้อมูลจากระบบที่เรา login ค้างไว้ในอีกแท็บ แต่เว็บสมัยนี้แยก frontend กับ API คนละโดเมนเป็นเรื่องปกติ จึงต้องมีทางให้ server บอกได้ว่ายอมให้ origin ไหนเข้ามา ทางนั้นคือ CORS (Cross-Origin Resource Sharing) server ตอบ header Access-Control-Allow-Origin มา เบราว์เซอร์เห็นแล้วถึงยอมส่ง response ให้ JavaScript อ่าน

บางคำขอต้องถามก่อน บางคำขอไม่ต้อง

CORS แบ่งคำขอข้ามโดเมนเป็นสองแบบ

คำขอธรรมดา (simple request) ส่งไปเลย ไม่ต้องถาม ต้องครบทุกข้อนี้ (MDN)

  • method เป็น GET, HEAD หรือ POST
  • ไม่ใส่ header เพิ่มเอง นอกจากกลุ่มพื้นฐานอย่าง Accept, Accept-Language, Content-Language, Content-Type
  • Content-Type เป็น application/x-www-form-urlencoded, multipart/form-data หรือ text/plain

ทั้งหมดนี้คือสิ่งที่ฟอร์ม HTML ส่งได้อยู่แล้วตั้งแต่ก่อนมี CORS server ทุกตัวจึงต้องรับมือกับคำขอแบบนี้ได้อยู่แล้ว เบราว์เซอร์เลยปล่อยไปก่อน แล้วค่อยดู header ใน response ว่าจะให้ JavaScript อ่านไหม

คำขออื่นทั้งหมด เบราว์เซอร์ต้องส่ง preflight ไปถามก่อน เช่น

  • PUT, PATCH, DELETE หรือ method ใหม่อย่าง QUERY
  • POST ที่ส่ง JSON (Content-Type: application/json)
  • คำขอที่มี Authorization: Bearer … หรือ header เอง เช่น X-Requested-With

สังเกตว่าแค่ส่ง JSON หรือแนบ token ก็ต้อง preflight แล้ว API ที่หน้าเว็บสมัยนี้เรียกจึงแทบทุกคำขอต้องผ่าน OPTIONS ก่อน

preflight บอกว่าจะขออะไร แล้ว server ตอบว่าอนุญาตแค่ไหน

สมมติหน้าเว็บที่ https://shop.test จะยิง PUT ส่ง JSON พร้อม token ไปที่ https://api.shop.test

sequenceDiagram
  participant B as เบราว์เซอร์
  participant S as api.shop.test
  B->>S: OPTIONS /orders/15 (ขอ PUT ได้ไหม)
  S-->>B: 204 อนุญาต origin, method, header
  B->>S: PUT /orders/15 (คำขอจริง)
  S-->>B: 200 พร้อม Access-Control-Allow-Origin

เบราว์เซอร์ส่งสองคำขอ โค้ดเราเขียน fetch แค่ครั้งเดียว

เบราว์เซอร์ส่ง OPTIONS ไปก่อน ไม่มี body มีแค่ header สามตัวที่บอกว่าจะขออะไร

OPTIONS /orders/15 HTTP/1.1
Host: api.shop.test
Origin: https://shop.test
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: authorization, content-type

เบราว์เซอร์เทียบคำตอบกับที่ขอไปทีละข้อ origin ตรงไหม method อยู่ในรายการไหม header ครบไหม ถ้าผ่านหมดก็ส่ง PUT ตัวจริงตามไป ถ้าขาดข้อเดียว เบราว์เซอร์หยุดตรงนั้น คำขอจริงไม่ถึง server เลย แล้วขึ้น error ใน console ประมาณนี้

Access to fetch at 'https://api.shop.test/orders/15' from origin 'https://shop.test'
has been blocked by CORS policy: Response to preflight request doesn't pass
access control check

Access-Control-Max-Age บอกให้เบราว์เซอร์จำคำตอบไว้กี่วินาที ช่วงนั้นยิงคำขอแบบเดิมไป URL เดิมก็ไม่ต้องถามซ้ำ Chrome จำได้สูงสุด 2 ชั่วโมง Firefox สูงสุด 24 ชั่วโมง (MDN)

เบราว์เซอร์เป็นคนตรวจ CORS ส่วน server แค่ตอบ header

ตรงนี้สับสนง่าย server แค่ตอบ header ตามที่ตั้งไว้ จะยอมหรือไม่ยอมเป็นเรื่องของเบราว์เซอร์ทั้งหมด

  • curl, Postman และ Http:: ใน Laravel ไม่สน CORS ยิงตรงได้หมด เพราะไม่มีหน้าเว็บของใครที่ต้องปกป้อง ถ้ายิงด้วย Postman ได้แต่หน้าเว็บยิงไม่ได้ แทบแน่นอนว่าเป็น CORS
  • CORS ไม่ได้กัน API จากคนนอก ใครก็ยิงด้วย curl ได้อยู่ดี ป้องกัน API ยังต้องใช้ auth, token และ rate limit ตามปกติ
  • คำขอธรรมดาถึง server ทุกครั้ง ต่อให้ origin ไม่ได้รับอนุญาต server ก็ทำงานไปแล้ว เบราว์เซอร์แค่ไม่ให้ JavaScript อ่านผล ฟอร์ม POST จึงยังต้องมี CSRF token เหมือนเดิม

Laravel ตอบ preflight ให้แล้ว

Laravel ใส่ middleware HandleCors ไว้ใน global middleware ตั้งแต่ติดตั้ง ถ้าคำขอเป็น OPTIONS ที่มี Access-Control-Request-Method และ path ตรงกับที่ตั้งไว้ middleware จะตอบ 204 กลับไปเลย ไม่เข้า route ไม่ผ่าน auth ไม่ถึง controller เราจึงไม่ต้องเขียน route OPTIONS เอง

ค่าตั้งต้นอยู่ใน framework ถ้าอยากแก้ให้ publish ออกมาก่อน

php artisan config:publish cors
return [
    'paths' => ['api/*', 'sanctum/csrf-cookie'],

    'allowed_methods' => ['*'],

    'allowed_origins' => ['https://shop.test'],

    'allowed_origins_patterns' => [],

    'allowed_headers' => ['*'],

    'exposed_headers' => [],

    'max_age' => 7200,

    'supports_credentials' => false,
];

ค่าที่ควรรู้

  • paths คือ path ที่ middleware ดูแล ค่าตั้งต้นครอบแค่ api/* ถ้าหน้าเว็บอีกโดเมนยิงมาที่ route ใน web.php เช่น /products/search ต้องเพิ่มเข้าไป ไม่อย่างนั้น preflight จะหลุดไปถึง router ซึ่งตอบกลับโดยไม่มี header CORS แล้วเบราว์เซอร์ก็บล็อก
  • allowed_origins ค่าตั้งต้นเป็น ['*'] คือรับทุกโดเมน API สาธารณะใช้ได้ แต่ถ้า API มีไว้ให้ frontend ของเราเท่านั้น ใส่โดเมนจริงดีกว่า โดเมนที่เปลี่ยนตามลูกค้าใช้ allowed_origins_patterns เป็น regex เช่น '#^https://[a-z0-9-]+\.shop\.test$#'
  • max_age ค่าตั้งต้นเป็น 0 เบราว์เซอร์จึงส่ง OPTIONS ก่อนทุกคำขอ ตั้งเป็น 7200 จะลดคำขอลงเกือบครึ่ง โดยเฉพาะหน้า dashboard ที่ยิง API ถี่ ๆ
  • supports_credentials ต้องเป็น true ถ้าหน้าเว็บส่ง cookie ไปด้วย เช่นใช้ Sanctum แบบ SPA (fetch ใส่ credentials: 'include') พอเปิดค่านี้ เบราว์เซอร์จะไม่ยอมรับ Access-Control-Allow-Origin: * แล้ว Laravel จะตอบ origin ที่ขอมาแทน แต่ใน allowed_origins เราควรใส่โดเมนจริงอยู่ดี

preflight ไม่ผ่าน ให้เริ่มดูที่แท็บ Network

เปิด DevTools แท็บ Network แล้วหาคำขอ OPTIONS ที่อยู่ก่อนคำขอจริง (Chrome ติดป้าย type เป็น preflight) ดูทั้ง status และ response header

  • ไม่มี Access-Control-Allow-Origin เลย path ไม่อยู่ใน paths หรือ origin ไม่อยู่ใน allowed_origins เช็คให้ตรงทั้ง scheme และ port ด้วย http://localhost:5173 กับ http://127.0.0.1:5173 ถือเป็นคนละ origin
  • 401 หรือ 403 มีอะไรบางอย่างตอบ OPTIONS ก่อนถึง HandleCors เช่น middleware ที่เราใส่ไว้ใน global ก่อนหน้า หรือ proxy อีกชั้นที่ขอ auth เบราว์เซอร์ไม่แนบ token ไปกับ preflight จึงไม่ผ่าน
  • 404 หรือ 405 จาก nginx มี config ใน nginx ที่ตอบ OPTIONS เอง หรือ return 405 กับ method ที่ไม่รู้จัก preflight จึงไม่ถึง PHP
  • preflight ผ่าน แต่คำขอจริงขึ้น CORS error ดู status ของคำขอจริง ถ้าเป็น 502 หรือ 504 แปลว่า nginx ตอบเองตอน PHP ล่ม หน้า error ของ nginx ไม่มี header CORS เบราว์เซอร์จึงรายงานเป็น CORS ทั้งที่ปัญหาจริงอยู่ฝั่ง PHP ให้ไปดู log ของ Laravel ต่อ

เช็คได้เร็วกว่าเปิดหน้าเว็บ ด้วยการยิง preflight เองจาก terminal ใส่ header แบบที่เบราว์เซอร์ส่ง แล้วดูว่า server ตอบอะไรกลับมา

curl -i -X OPTIONS https://api.shop.test/orders/15 -H "Origin: https://shop.test" -H "Access-Control-Request-Method: PUT" -H "Access-Control-Request-Headers: authorization, content-type"

ถ้า frontend กับ API อยู่โดเมนเดียวกันได้ ก็ไม่ต้องมี CORS เลย Laravel กับ Inertia หรือ Blade ที่เสิร์ฟหน้าเว็บกับ API จาก origin เดียวกัน ไม่มี preflight สักคำขอ รายละเอียดทุกเงื่อนไขของ CORS อ่านต่อได้ที่ MDN: Cross-Origin Resource Sharing

อ่านต่อ

ล่าสุด

ดูทั้งหมด →