ข้าม​ไป​ยัง​เนื้อหา

ทรง​ที่​การ​เขียน​ไม่​เคย​เกิด OPTIONS นับ​ได้ 1 และ POST นับ​ได้ 0

บท​ที่ 5 วัด​ทรง​ที่​เบราว์เซอร์​ไม่​ยอม​ให้​หน้า​เว็บ​อ่าน​คำ​ตอบ แล้ว​แถว​ก็​ยัง​ลง​ไป​แล้ว บท​นี้​ถาม​กลับ​ข้าง คือ​มี​ทรง​ไหน​บ้าง​ที่​คำขอ​จริง​ไม่​เคย​ออก​จาก​เบราว์เซอร์​เลย

เส้น​แบ่ง​ไม่​ได้​อยู่​ที่​ว่า​ข้าม​โดเมน​หรือ​ไม่ ทรง​ทั้ง​สอง​ใน​บท​นี้​ข้าม​ต้นทาง​เหมือน​กัน ยิง​ไป​ที่​ปลาย​ทาง​เดียวกัน และ​ต่าง​กัน​แค่ header ที่​หน้า​เว็บ​ประกาศ​เพิ่ม​เข้า​มา

📏 ตัวเลข​ฝั่ง​เบราว์เซอร์​มา​จาก​เบราว์เซอร์​จริง

ตัวเลข​สอง​แถว​หลัก​ของ​บท​นี้​ไม่​ได้​มา​จาก HTTP client มัน​มา​จาก chromium ที่​รัน​จริง ผ่าน scripts/clinic-cross-origin/browser/runner.mjs ทรง​ละ 20 รอบ

ผล​ที่​ตรึง​ไว้​อยู่​ที่ scripts/clinic-cross-origin/browser/results-20x-chromium149.json ซึ่ง​บันทึก​ว่า distinctResults เป็น 1 ทุก​ทรง คือ 20 รอบ​ให้​ผล​เท่า​กัน​ทุก​ช่อง

ส่วน​ข้อ F1 ท้าย​บท​มา​จาก python3 scripts/clinic-cross-origin/probe.py ตาม​เดิม

Fetch Standard แบ่ง​คำขอ​เป็น​สอง​พวก พวก​ที่​ออก​ไป​ได้​เลย กับ​พวก​ที่​ต้อง​ถาม​ปลาย​ทาง​ก่อน​หนึ่ง​ใบ

simple requestsimple requestคำขอ​ที่​เบราว์เซอร์​ส่ง​ออก​ไป​ได้​เลย โดย​ไม่​ถาม​ปลาย​ทาง​ก่อน · คำ​นี้​เป็น​คำ​ของ MDN ที่มา​จาก​สเปก CORS ใบ​เก่า ส่วน Fetch Standard ไม่​ได้​ใช้​คำ​นี้ · ทรง​ที่ 1 ของ spike วัด​แล้ว​ว่า server นับ POST ได้ 1 ครั้ง ขณะ​ที่​ฝั่ง JavaScript เห็น TypeError: Failed to fetch คือ​คำขอ​ที่​เบราว์เซอร์​ส่ง​ออก​ไป​ได้​เลย ส่วน​คำขอ​ที่​ตก​นอก​กรอบ​นั้น เบราว์เซอร์​จะ​ยิง OPTIONS ไป​ถาม​ปลาย​ทาง​ก่อน​หนึ่ง​ใบ ใบ​ที่​ยิง​ไป​ถาม​คือ preflightpreflight requestคำขอ OPTIONS ที่​เบราว์เซอร์​ยิง​ไป​ถาม​ก่อน​คำขอ​จริง · ทรง​ที่​ต้อง​ถาม​ก่อน​และ​ไม่​ได้​รับ​อนุญาต วัด​แล้ว​ว่า​ได้ OPTIONS = 1 และ POST = 0 คือ​การ​เขียน​ไม่​เคย​เกิด​ขึ้น​เลย

คำ​ว่า simple request ไม่ใช่​คำ​ของ Fetch Standard MDN เขียน​ไว้​เอง​ว่า​คำ​นี้​มา​จาก สเปก CORS ใบ​เก่า​ที่​เลิก​ใช้​แล้ว และ​ใบ​ที่​นิยาม CORS อยู่​ตอน​นี้​ไม่​ได้​ใช้​คำ​นี้​เลย บท​นี้​ยัง​ใช้​คำ​นี้ เพราะ​เป็น​คำ​ที่​ผู้​อ่าน​ค้น​เจอ แต่​กรอบ​ของ​มัน​เป็น​ของ MDN ไม่ใช่​ของ​สเปก

เงื่อนไข​ที่​บท​นี้​ใช้​มี​สาม​ข้อ ทั้ง​สาม​มา​จาก​กรอบ​ของ MDN

  • method อยู่​ใน​ชุด GET HEAD POST
  • header ที่​หน้า​เว็บ​ตั้ง​เอง อยู่​ใน​ชุด CORS-safelisted request-header
  • ถ้า​มี Content-Type ค่า​ของ​มัน​เป็น application/x-www-form-urlencoded หรือ multipart/form-data หรือ text/plain

สาม​ข้อ​นี้​คือ​ส่วน​ที่ เอกสาร​กำหนด ไม่ใช่​ส่วน​ที่ probe วัด ทั้ง MDN และ​สเปก​มี​เงื่อนไข มากกว่า​นี้ และ​บท​นี้​อ้าง​เฉพาะ​สาม​ข้อ​ที่​ทรง​ใน​บท​นี้​ตก​กรอบ​เพราะ​มัน​โดยตรง

ส่วน​ที่​ตัดสิน​จริง​ว่า​คำขอ​ใบ​ไหน​ต้อง​ถูก​ถาม​ก่อน อยู่​ใน​ขั้น​ตอน HTTP fetch ของ Fetch Standard ไม่​ได้​อยู่​ใน​หัวข้อ​ที่​นิยาม​ว่า preflight คือ​อะไร

ทั้ง​คู่​เป็น POST ข้าม​ต้นทาง และ​ทั้ง​คู่​ตก​นอก​กรอบ simple request ด้วย​คนละ​เหตุผล

runner.mjs — ทรง​ที่ 3 กับ​ทรง​ที่ 4 ใน​ตาราง SHAPES
{
id: "3-json-content-type",
desc: "POST Content-Type: application/json, preflight answered but header not allowed",
path: "/x/json-nopreflight",
init: { method: "POST", body: '{"a":1}', headers: { "content-type": "application/json" } },
},
{
id: "4-custom-header",
desc: "POST with custom header x-kaen-token, preflight fully allows it",
path: "/x/custom-allow",
init: { method: "POST", body: "a=1", headers: { "x-kaen-token": "t" } },
},

ทรง​ที่ 3 ตั้ง content-type เป็น application/json ซึ่ง​ไม่ใช่​หนึ่ง​ใน​สาม​ค่า​ข้าง​บน ทรง​ที่ 4 ตั้ง header ชื่อ x-kaen-token ซึ่ง​ไม่​อยู่​ใน​ชุด safelist

หน้า​เว็บ​อยู่​ที่ http://127.0.0.1:55472 และ API อยู่​ที่ http://127.0.0.1:55471 ต่าง​กัน​ที่ port อย่าง​เดียว ซึ่ง​นับ​เป็น​คนละ originoriginชุด​ของ scheme, host และ port ที่​เบราว์เซอร์​ใช้​ระบุ​ว่า​หน้า​เว็บ​มา​จาก​ไหน · ต่าง​กัน​แม้​ช่อง​เดียว​ก็​นับ​เป็น​คนละ​ต้นทาง แล้ว

ทรง​หนึ่ง​ไม่​เคย​ตอบ​ชื่อ header ที่​ถูก​ถาม​กลับ​มา อีก​ทรง​ตอบ​ชื่อ​นั้น​ตรง​ตัว

server.mjs — กฎ header ต่อ​ทรง · สอง​ทรง​ที่​บท​นี้​ยิง​ใส่
} else if (shape === "json-nopreflight") {
// Answers the preflight, but never allows the Content-Type header.
h["access-control-allow-origin"] = PAGE_ORIGIN;
if (req.method === "OPTIONS") h["access-control-allow-methods"] = "POST";
} else if (shape === "custom-allow") {
h["access-control-allow-origin"] = PAGE_ORIGIN;
if (req.method === "OPTIONS") {
h["access-control-allow-methods"] = "POST";
h["access-control-allow-headers"] = "x-kaen-token";
}

json-nopreflight ตอบ Access-Control-Allow-Methods ให้ แต่​ไม่​เคย​ตอบ Access-Control-Allow-Headers เลย​สัก​ใบ คำถาม​ที่​เบราว์เซอร์​ถาม​ไป​จึง​ไม่มี​คำ​ตอบ

custom-allow ตอบ​ชื่อ x-kaen-token กลับ​มา​ตรง​ตัว ทั้ง​สอง​ทรง​ตอบ OPTIONS เหมือน​กัน ต่าง​กัน​แค่​ว่า​ใน​คำ​ตอบ​นั้น​มีชื่อ header ที่​ถูก​ถาม​อยู่​หรือ​ไม่

ตัว​นับ​เพิ่ม​ค่า​ก่อน​ตอบ คำขอ​ที่​ถึง​ปลาย​ทาง​จึง​ถูก​นับ​ไว้​ก่อน​หน้า​เว็บ​จะ​รู้​ผล

server.mjs — ลำดับ​ของ​การ​นับ ซึ่ง​เป็น​เหตุผล​ที่​ตัวเลข​นี้​เชื่อ​ได้
// Count BEFORE responding: the browser cannot settle its promise until we answer,
// so the count is always written before the page observes anything.
bump(runId, req.method, req);

ถ้า​ให้​ฝั่ง​เบราว์เซอร์​เป็น​คน​รายงาน​ว่า​คำขอ​ออก​ไป​หรือ​ไม่ ข้อ​อ้าง​ทั้ง​บท​จะ​พัง​ทันที เพราะ​สิ่ง​ที่​หน้า​เว็บ​เห็น คือ​สิ่ง​ที่​บท​นี้​กำลัง​จะ​บอก​ว่า​เชื่อ​ไม่​ได้

ตัว​นับ​แยก​ตาม method และ​แยก​ตาม id ของ​รอบ เลข​ที่​อ่าน​ได้​จึง​เป็น​จำนวน​คำขอ​ที่​ถึง​ปลาย​ทาง​จริง ไม่ใช่​จำนวน​ที่​หน้า​เว็บ​ตั้งใจ​จะ​ยิง

ทรง​ที่​ปลาย​ทาง​ไม่​อนุญาต header นั้น การ​เขียน​ไม่​เคย​เกิด​ขึ้น​เลย​สัก​ครั้ง

ทรงheader ที่​หน้า​เว็บ​ประกาศOPTIONS ที่​นับ​ได้POST ที่​นับ​ได้สิ่ง​ที่ fetch คืน
3-json-content-typecontent-type เป็น application/json10reject ด้วย TypeError
4-custom-headerx-kaen-token11resolve · status เป็น 200

ทรง​ที่ 4 คืน type เป็น cors และ​หน้า​เว็บ​อ่าน body ได้​ยาว 101 ตัว​อักษร ส่วน​ทรง​ที่ 3 ไม่มี body ให้​อ่าน​เลย เพราะ promise ของ fetch ไม่​เคย resolve

ช่อง​ที่​แบก​บท​นี้​คือ​ช่อง POST ของ​แถว​บน เลข 0 ใน​ช่อง​นั้น​แปล​ว่า​คำขอ​ที่​จะ​เขียน ไม่​เคย​ออก​จาก​เบราว์เซอร์ ไม่ใช่​ว่า​ออก​ไป​แล้ว​ถูก​ปฏิเสธ​ที่​ปลาย​ทาง

ทั้ง​สอง​ทรง​รัน​ทรง​ละ 20 รอบ และ distinctResults เป็น 1 ทั้ง​คู่ คือ 20 รอบ​ให้​ผล​เท่า​กัน​ทุก​ช่อง รวม​ทั้ง​ช่อง​ที่​นับ​คำขอ

ข้อความ​ที่ JavaScript ได้​รับ ไม่​ได้​บอก​ว่า server ได้​รับคำ​ขอ​ไป​แล้ว​หรือ​ยัง

console ของ​ทรง​ที่ 3 · <RUN> คือ id ของ​รอบ ที่​ตัว​รัน​แทนที่​ก่อน​เทียบ​ผล
error: Access to fetch at 'http://127.0.0.1:55471/x/json-nopreflight?run=<RUN>' from origin 'http://127.0.0.1:55472' has been blocked by CORS policy: Request header field content-type is not allowed by Access-Control-Allow-Headers in preflight response.
error: Failed to load resource: net::ERR_FAILED

ข้อความ​ใน console บอก​สาเหตุ​ไว้​ตรง​ตัว แต่​ค่าที่ JavaScript ได้​จริง​คือ TypeError: Failed to fetch ซึ่ง​ไม่มี​คำ​ไหน​บอก​ว่า​ปลาย​ทาง​ได้​รับคำ​ขอ​ไป​กี่​ใบ

บท​ที่ 5 วัด​อีก​ทรง​หนึ่ง​ที่ JavaScript คาย​ข้อความ TypeError: Failed to fetch ใบ​เดียวกัน​นี้​ออก​มา แต่​ที่​ฝั่ง server คำขอ​จริง​เดิน​ทาง​ถึง​ปลาย​ทาง​ไป​แล้ว

สอง​ทรง​นั้น​แยก​กัน​ไม่​ได้​เลย​ถ้า​ดู​จาก​ฝั่ง​หน้า​เว็บ ตัว​ที่​แยก​มัน​ออก​จาก​กัน คือ​จำนวน​คำขอ​ที่ server นับ​ได้ ซึ่ง​เป็น​ตัวเลข​ที่​หน้า​เว็บ​มอง​ไม่​เห็น

probe ยิง OPTIONS ด้วย​มือ ไม่มี​เบราว์เซอร์​อยู่​ใน​การ​วัด​นี้​สัก​ตัว

probe.py — preflight ที่​ยิง​ด้วย​มือ ที​ละ​ต้นทาง
for label, origin in (("evil", EVIL_ORIGIN), ("good", GOOD_ORIGIN)):
anon = Session(port)
pre, d = with_delta(lambda o=origin, a=anon: a.send(
"OPTIONS", "/visits",
{"Origin": o, "Access-Control-Request-Method": "POST",
"Access-Control-Request-Headers": "content-type"}))
out[f"preflight_{label}"] = {"status": pre["status"], "rowDelta": d}
out[f"preflight_{label}Aca"] = aca_headers(pre)

ทรง cors-none ไม่มี bean ของ CORS อยู่​เลย probe ยิง OPTIONS /visits ใส่​มัน พร้อม Origin ของ​ผู้​โจมตี แล้ว​นับ header ตระกูล Access-Control-Allow-* ใน​คำ​ตอบ

ที่​นับ​ได้​คือ 0 ตัว นี่​คือ​ข้อ F1 ของ probe คำ​ตอบ​ใบ​นั้น​ไม่มี​ชื่อ header สัก​ชื่อ​ใน​ตระกูล​นั้น จึง​ไม่มี​อะไร​ที่​อนุญาต​อะไร​ได้​เลย

ส่วน​การ​ที่​เบราว์เซอร์​จะ​ไม่​ยิง​คำขอ​จริง​ต่อ เมื่อ​ได้​คำ​ตอบ​แบบ​นั้น เป็น​สิ่ง​ที่ Fetch Standard กำหนด ไม่ใช่​สิ่ง​ที่​คำขอ​ใบ​นี้​วัด นี่​คือ​รอย​ต่อ​ของ​หลัก​ฐาน​สอง​ชนิด

จุด​ที่​เส้น​ทาง​แยก อยู่​ที่​คำ​ตอบ​ของ OPTIONS ไม่ใช่​ที่​ชื่อ​โดเมน​ของ​ปลาย​ทาง

flowchart TB
  F["fetch จากหน้าเว็บ ข้ามต้นทาง"] --> S{"เข้ากรอบ simple request ของ Fetch Standard ไหม"}
  S -->|"เข้ากรอบ"| DIRECT["คำขอจริงออกไปเลย ไม่มี OPTIONS นำหน้า · บทที่ 5"]
  S -->|"ตกกรอบ"| PRE["เบราว์เซอร์ยิง OPTIONS ไปถามก่อนหนึ่งใบ"]
  PRE --> A{"คำตอบมีชื่อ header ที่ถามไปหรือไม่"}
  A -->|"ไม่มี · ทรงที่ 3"| STOP["POST ไม่เคยออกเดินทาง · OPTIONS = 1 · POST = 0"]
  A -->|"มี · ทรงที่ 4"| GO["POST ออกเดินทาง · OPTIONS = 1 · POST = 1"]

คำ​บรรยาย​ภาพ: คำขอ​สอง​ใบ​ที่​ต่าง​กัน​แค่ header ที่​ประกาศ เดิน​คนละ​ทาง​ตั้งแต่​ก่อน​ออก​จาก เบราว์เซอร์ และ​ทาง​ที่​ต่าง​กัน​นั้น​วัด​ได้​เป็น​จำนวน​คำขอ​ที่​ปลาย​ทาง​นับ​ได้

ตัวเลข​ทุก​ตัว​ผูก​กับ​รุ่น​ที่​ระบุ​ไว้

ตัวเลข​ฝั่ง​เบราว์เซอร์​วัด​บน chromium 149.0.7827.55 ที่ Playwright 1.61.0 ปัก​ไว้ ส่วน​ข้อ F1 วัด​บน Spring Boot 4.1.1 · Kotlin 2.4.10 · JDK 25

เส้น​แบ่ง simple request เป็น​ข้อ​กำหนด​ของ Fetch Standard ส่วน OPTIONS = 1 กับ POST = 0 เป็น​ผล​ของ​ทรง​ที่ spike สร้าง​ขึ้น รุ่น​อื่น​เป็น​คนละ​การ​วัด

ทั้ง​สอง​ทรง​ใน​บท​นี้ ปลาย​ทาง​ตอบ​ทุก​ใบ​ที่​ถูก​ถาม และ​คน​ตัดสิน​คือ​เบราว์เซอร์

ทรง​ที่ 3 ถูก​หยุด​ด้วย​เบราว์เซอร์ ปลาย​ทาง​ไม่​ได้​ปฏิเสธ​อะไร​เลย มัน​ตอบ OPTIONS ตาม​ปกติ แล้ว​เบราว์เซอร์​อ่าน​คำ​ตอบ​นั้น​แล้ว​ตัดสิน​ใจ​ไม่​ยิง​ต่อ

แต่​ยัง​มี​อีก​กรณี​หนึ่ง คือ​คำขอ​ที่​ถึง server แล้ว และ server เป็น​คน​ปฏิเสธ​เอง ใน​กรณี​นั้น​เบราว์เซอร์​ไม่​ได้​เป็น​คน​ตัดสิน และ​หลัก​ฐาน​ก็​อยู่​คนละ​ที่

บท​ที่ 7 วัด​กรณี​นั้น มัน​ถาม​ด้วย​ว่า​อะไร​คือ​สวิตช์​ที่​ทำให้ server เริ่ม​ปฏิเสธ​เอง และ​คำ​ตอบ​ไม่ใช่​บรรทัด​ที่​คน​ส่วน​ใหญ่​คิด​ว่า​ใช่


🔗 อ้างอิง​ต้นทาง​ของ​บท​นี้
  • Fetch Standard — HTTP fetch (เข้า​ถึง 2026-09-05) — ขั้น​ตอน​ที่​ตัดสิน​ว่า​คำขอ​ใบ​ไหน​ต้อง​ถูก​ถาม​ก่อน โดย​ดู method กับ CORS-unsafe request-header names · บท​นี้​อ้าง​มัน​เป็น​สเปก​เท่านั้น ไม่​ได้​วัด​มัน

  • Fetch Standard — CORS-preflight request (เข้า​ถึง 2026-09-05) — นิยาม​ว่า​คำขอ preflight คือ​อะไร คือ​คำขอ CORS ที่​ใช้ method OPTIONS และ​พก Access-Control-Request-Method มา​ด้วย · หัวข้อ​นี้​ไม่​ได้​บอก​ว่า​ใบ​ไหน​ต้อง​ถูก​ถาม

  • MDN — Cross-Origin Resource Sharing (CORS) (เข้า​ถึง 2026-09-05) — ที่มา​ของ​คำ​ว่า simple request และ​ของ​เงื่อนไข​สาม​ข้อ · MDN เขียน​ไว้​เอง​ว่า​คำ​นี้​มา​จาก​สเปก CORS ใบ​เก่า และ Fetch Standard ไม่​ได้​ใช้​คำ​นี้

  • Fetch Standard — CORS-safelisted request-header (เข้า​ถึง 2026-09-05) — ที่มา​ของ​ชุด header และ​ของ​สาม​ค่าที่ Content-Type เป็น​ได้

  • ตัวเลข​ฝั่ง​เบราว์เซอร์​มา​จาก scripts/clinic-cross-origin/browser/runner.mjs กับ server.mjs และ​ตรึง​ไว้​ที่ results-20x-chromium149.json · ทรง​ละ 20 รอบ

  • ข้อ F1 มา​จาก scripts/clinic-cross-origin/probe.py · ต้อง​สั่ง scripts/clinic-cross-origin/up.sh ก่อน ซึ่ง​ยก container ของ​คอร์ส​นี้​เอง​ขึ้น​ที่ port 55437

เช็กความเข้าใจ — บทที่ 6

ข้อ 1 / 3

ในทรง `3-json-content-type` server นับคำขอได้กี่ครั้ง แยกตาม method