ทรงที่การเขียนไม่เคยเกิด 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 ตามเดิม
เส้นแบ่งอยู่ที่ header ไม่ใช่ที่โดเมน
หัวข้อที่มีชื่อว่า “เส้นแบ่งอยู่ที่ header ไม่ใช่ที่โดเมน”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 อยู่ในชุด
GETHEADPOST - header ที่หน้าเว็บตั้งเอง อยู่ในชุด CORS-safelisted request-header
- ถ้ามี
Content-Typeค่าของมันเป็นapplication/x-www-form-urlencodedหรือmultipart/form-dataหรือtext/plain
สามข้อนี้คือส่วนที่ เอกสารกำหนด ไม่ใช่ส่วนที่ probe วัด ทั้ง MDN และสเปกมีเงื่อนไข มากกว่านี้ และบทนี้อ้างเฉพาะสามข้อที่ทรงในบทนี้ตกกรอบเพราะมันโดยตรง
ส่วนที่ตัดสินจริงว่าคำขอใบไหนต้องถูกถามก่อน อยู่ในขั้นตอน HTTP fetch ของ Fetch Standard ไม่ได้อยู่ในหัวข้อที่นิยามว่า preflight คืออะไร
สองทรงที่วัด ต่างกันที่ header ที่ประกาศ
หัวข้อที่มีชื่อว่า “สองทรงที่วัด ต่างกันที่ header ที่ประกาศ”ทั้งคู่เป็น POST ข้ามต้นทาง และทั้งคู่ตกนอกกรอบ simple request ด้วยคนละเหตุผล
{ 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 ที่ถูกถามกลับมา อีกทรงตอบชื่อนั้นตรงตัว
} 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 ไม่ใช่ที่หน้าเว็บ
หัวข้อที่มีชื่อว่า “นับที่ server ไม่ใช่ที่หน้าเว็บ”ตัวนับเพิ่มค่าก่อนตอบ คำขอที่ถึงปลายทางจึงถูกนับไว้ก่อนหน้าเว็บจะรู้ผล
// 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 ของรอบ เลขที่อ่านได้จึงเป็นจำนวนคำขอที่ถึงปลายทางจริง ไม่ใช่จำนวนที่หน้าเว็บตั้งใจจะยิง
ที่นับได้ · OPTIONS หนึ่งใบ และ POST ศูนย์ใบ
หัวข้อที่มีชื่อว่า “ที่นับได้ · OPTIONS หนึ่งใบ และ POST ศูนย์ใบ”ทรงที่ปลายทางไม่อนุญาต header นั้น การเขียนไม่เคยเกิดขึ้นเลยสักครั้ง
| ทรง | header ที่หน้าเว็บประกาศ | OPTIONS ที่นับได้ | POST ที่นับได้ | สิ่งที่ fetch คืน |
|---|---|---|---|---|
3-json-content-type | content-type เป็น application/json | 1 | 0 | reject ด้วย TypeError |
4-custom-header | x-kaen-token | 1 | 1 | resolve · status เป็น 200 |
ทรงที่ 4 คืน type เป็น cors และหน้าเว็บอ่าน body ได้ยาว 101 ตัวอักษร
ส่วนทรงที่ 3 ไม่มี body ให้อ่านเลย เพราะ promise ของ fetch ไม่เคย resolve
ช่องที่แบกบทนี้คือช่อง POST ของแถวบน เลข 0 ในช่องนั้นแปลว่าคำขอที่จะเขียน
ไม่เคยออกจากเบราว์เซอร์ ไม่ใช่ว่าออกไปแล้วถูกปฏิเสธที่ปลายทาง
ทั้งสองทรงรันทรงละ 20 รอบ และ distinctResults เป็น 1 ทั้งคู่
คือ 20 รอบให้ผลเท่ากันทุกช่อง รวมทั้งช่องที่นับคำขอ
error ใบเดียวกัน ผลต่อข้อมูลคนละเรื่อง
หัวข้อที่มีชื่อว่า “error ใบเดียวกัน ผลต่อข้อมูลคนละเรื่อง”ข้อความที่ JavaScript ได้รับ ไม่ได้บอกว่า server ได้รับคำขอไปแล้วหรือยัง
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 นับได้ ซึ่งเป็นตัวเลขที่หน้าเว็บมองไม่เห็น
ฝั่ง server · คำตอบที่ไม่มีอะไรอยู่ในนั้น
หัวข้อที่มีชื่อว่า “ฝั่ง server · คำตอบที่ไม่มีอะไรอยู่ในนั้น”probe ยิง
OPTIONSด้วยมือ ไม่มีเบราว์เซอร์อยู่ในการวัดนี้สักตัว
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