เบราว์เซอร์บอกว่าบล็อก แล้ว csrf_visit ได้แถวเพิ่มหนึ่งแถว
console ของเบราว์เซอร์ขึ้นบรรทัดว่าคำขอถูกบล็อกด้วย CORS policy บรรทัดนั้นอ่านเหมือน คำตอบสุดท้าย และผู้อ่านส่วนมากหยุดตรงนั้น
แต่บรรทัดนั้นพูดถึงสิ่งที่เกิดในเบราว์เซอร์ล้วน มันไม่ได้พูดถึงฐานข้อมูลของปลายทางเลย
บทนี้จึงถามคำถามเดียว คือตาราง csrf_visit ได้แถวเพิ่มกี่แถว
ด้านที่หนึ่งคือ probe ฝั่ง server scripts/clinic-cross-origin/probe.py
ซึ่งสร้างแอป Spring Boot ขึ้นทีละทรงแล้วนับแถวในตาราง csrf_visit
ด้านที่สองคือ spike ฝั่งเบราว์เซอร์ scripts/clinic-cross-origin/browser/
ซึ่งเปิด server Node สองตัวเป็นสองต้นทาง แล้วให้ chromium จริงยิงคำขอ
ทั้งสองด้านไม่ได้ยิงแอปตัวเดียวกัน ตัวเลขของแต่ละด้านจึงเป็นข้ออ้างของด้านนั้น และบทนี้ระบุที่มาของทุกตัวเลขไว้ตรงจุดที่ใช้มัน
ทรงที่ไม่มีค่า CORS อยู่สักบรรทัด
หัวข้อที่มีชื่อว่า “ทรงที่ไม่มีค่า CORS อยู่สักบรรทัด”
cors-noneไม่มี fileCorsConfig.ktและไม่มีบรรทัดhttp.cors { }อยู่ในSecConfig.kt
"cors-none": {"csrf": False, "cors": None, "auth": "session", "samesite": None},ช่อง cors เป็น None แปลว่า probe ไม่เขียน file CorsConfig.kt ให้ทรงนี้เลย
นี่คือรูปที่แอปจำนวนมากอยู่ตอนเริ่มต้น คือไม่มีใครเคยพิมพ์คำว่า CORS ลงไป
CORSCross-Origin Resource Sharing (CORS)ชุดกฎที่บอกเบราว์เซอร์ว่าหน้าเว็บจากต้นทางหนึ่ง อ่านคำตอบจากอีกต้นทางได้หรือไม่ · บังคับที่เบราว์เซอร์เป็นหลัก จึงกันการอ่าน ไม่ได้กันการเขียนโดยตัวมันเอง คือชุดกฎที่บอกเบราว์เซอร์ว่าหน้าเว็บจากต้นทางหนึ่ง อ่านคำตอบจากอีกต้นทางได้หรือไม่ กฎชุดนี้ถูกบังคับที่เบราว์เซอร์เป็นหลัก ไม่ใช่ที่ server
ช่อง csrf เป็น False ด้วย ทรงนี้จึงไม่มีอะไรตรวจ CSRF token เลย
สิ่งที่บทนี้วัดจึงเป็นผลของ CORS ล้วน ไม่มีกลไกอื่นปนเข้ามา
คำขอข้ามต้นทาง ที่ยิงจากฝั่ง server
หัวข้อที่มีชื่อว่า “คำขอข้ามต้นทาง ที่ยิงจากฝั่ง server”probe ตั้ง
Originของผู้โจมตีเองแล้วยิงตรง ไม่มีเบราว์เซอร์อยู่ในการวัดชุดนี้สักคำขอเดียว
# E · POST ข้ามต้นทาง · นับ header ตระกูล Access-Control-Allow-* cross = probe_post(s, {"Origin": EVIL_ORIGIN} if not tok else {"Origin": EVIL_ORIGIN, tok["headerName"]: tok["token"]}) out["crossOriginPost"] = slim_with_body(cross) out["crossOriginPostAca"] = aca_headers(cross["res"])EVIL_ORIGIN คือ https://evil.example และ s คือ session ที่ login สำเร็จแล้ว
ทรงนี้ไม่มี CSRF token ให้ถือ ตัวแปร tok จึงเป็น None และคำขอพก Origin มาช่องเดียว
คำขอที่ยิงใส่ทรง cors-none | รหัส | แถวใน csrf_visit |
|---|---|---|
POST /visits · ประกาศ Origin ของผู้โจมตี | 200 | +1 |
GET /visits/touch · ประกาศ Origin และ Referer ของผู้โจมตี | 200 | +1 |
body ของแถวแรกคือ {"id":<n>,"who":"kaen-a"} ซึ่ง probe แทนตัวเลขทุกตัวด้วย <n>
ก่อนเทียบ เพราะ id ของแถวขยับทุกรอบ ส่วนชื่อผู้เรียกไม่ขยับ
แถวที่สองยิงปลายทางที่บทที่ 4 อธิบายไว้ ผลของมันในทรงนี้ก็เท่ากับแถวแรกทุกช่อง คือคำขอผ่าน และตารางได้แถวเพิ่ม
นับ header ที่คำตอบพกกลับมา
หัวข้อที่มีชื่อว่า “นับ header ที่คำตอบพกกลับมา”คำตอบใบที่เขียนแถวลงไปแล้ว ไม่มี header ตระกูล
Access-Control-Allow-*ออกมาสักตัว
def aca_headers(res: dict) -> dict: """นับ header ตระกูล `Access-Control-Allow-*` ทีละชื่อ · และรวมทั้งหมด""" out = {} for k, v in res["headers"]: if k.lower().startswith("access-control-allow-"): out.setdefault(k.lower(), []).append(v) return {"รวม": sum(len(v) for v in out.values()), "ชื่อ": {k: v for k, v in sorted(out.items())}}ตัวนับนี้อ่าน header ทุกใบของคำตอบเดียวกับที่ให้ 200 และ +1 ข้างบน
ผลที่ได้คือ รวม เป็น 0 และ ชื่อ เป็นตารางว่าง
Access-Control-Allow-OriginAccess-Control-Allow-Originheader ที่ server ใช้บอกว่าต้นทางใดอ่านคำตอบนี้ได้ · ทรง cors-none ตอบ 200 และแถวเพิ่มหนึ่งแถว โดยไม่มี header นี้ออกมาสักตัว คือ header ที่ server ใช้บอกว่าต้นทางใดอ่านคำตอบนี้ได้ ในทรงนี้มันไม่ออกมา และแถวก็ยังลง สองข้อนี้เป็นคนละเรื่องกัน
ในทรงนี้ บน Spring Boot 4.1.1 ปลายทางไม่ได้ปฏิเสธอะไร มันแค่เงียบ คำขอเดินเข้ามาถึง controller ตามปกติ และการเขียนเกิดขึ้นจนจบก่อนที่ใครจะได้ตัดสินอะไร
แล้วหน้าเว็บจริงเห็นอะไร
หัวข้อที่มีชื่อว่า “แล้วหน้าเว็บจริงเห็นอะไร”spike ให้ chromium จริงยิง POST ข้ามต้นทางใส่ปลายทางของตัวเอง แล้วอ่านผลทั้งฝั่ง JavaScript และฝั่งปลายทาง
const SHAPES = [ { id: "1-simple-no-cors", desc: "simple cross-origin POST, endpoint sends NO CORS headers", path: "/x/plain-nocors", init: { method: "POST", body: "a=1" }, },หน้าเว็บอยู่ที่ http://127.0.0.1:55472 ปลายทางอยู่ที่ http://127.0.0.1:55471
และปลายทางนับทุกคำขอที่ได้รับ ก่อน จะตอบกลับ จำนวนที่นับได้จึงไม่ขึ้นกับผลของ fetch
| ช่องที่วัดในทรงที่ 1 | ค่าที่วัดได้ |
|---|---|
| จำนวนรอบที่รัน | 20 |
| จำนวนผลที่ต่างกัน | 1 |
fetch คืนคำตอบให้หน้าเว็บไหม | ไม่คืน · TypeError: Failed to fetch |
| method ที่ปลายทางนับได้ | POST = 1 |
| ต้นทางที่ปลายทางเห็น | http://127.0.0.1:55472 |
| ปลายทางเห็น cookie ติดมากับคำขอไหม | ไม่เห็น |
ช่องสุดท้ายคือเหตุผลที่ตัวเลขของ spike ต่อกับตัวเลขของ probe ตรงๆ ไม่ได้ คำขอของ spike ไม่พก cookie ไปด้วย ส่วนคำขอที่ให้ 200 กับ +1 ข้างบนนั้นพก session ที่ login แล้ว
บรรทัดที่ chromium เขียนลง console คือ
error: Access to fetch at 'http://127.0.0.1:55471/x/plain-nocors?run=<RUN>' from origin 'http://127.0.0.1:55472' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.error: Failed to load resource: net::ERR_FAILEDข้อความนี้พูดถึง header ที่ไม่มีอยู่ในคำตอบ ไม่ได้พูดถึงคำขอที่ไม่ได้ส่ง
และช่อง POST ที่นับได้เท่ากับ 1 คือหลักฐานว่าคำขอออกเดินทางไปแล้วจริง
เส้นแบ่งอยู่ที่การอ่าน ไม่ใช่ที่การส่ง
หัวข้อที่มีชื่อว่า “เส้นแบ่งอยู่ที่การอ่าน ไม่ใช่ที่การส่ง”ในเส้นของ spike คำขอเดินถึงปลายทางครบ แล้วจึงถูกหยุดที่ขั้นอ่านคำตอบซึ่งอยู่ในเบราว์เซอร์
flowchart TB
subgraph SP["spike · Node สองต้นทาง · chromium จริง"]
P["หน้าเว็บที่ 127.0.0.1:55472 · เรียก fetch ไปยัง 127.0.0.1:55471"] --> S["ปลายทางรับคำขอ · นับ POST = 1"]
S --> R["ปลายทางตอบกลับ · คำตอบถึงเบราว์เซอร์"]
R --> C{"คำตอบมี Access-Control-Allow-Origin ไหม"}
C -->|"ไม่มี"| B["เบราว์เซอร์ไม่ส่งคำตอบให้หน้าเว็บ · fetch ขึ้น TypeError: Failed to fetch"]
C -->|"มี"| A["หน้าเว็บอ่านคำตอบได้"]
end
subgraph PB["probe · ทรง cors-none · ไม่มีเบราว์เซอร์ในเส้นนี้"]
Q["POST ข้ามต้นทาง จาก session ที่ login แล้ว"] --> T["ตอบ 200 · csrf_visit เพิ่มหนึ่งแถว · ACA header 0 ตัว"]
end
คำบรรยายภาพ: สองเส้นนี้เป็นสองการวัด ไม่ใช่เส้นเดียวที่ต่อกัน เส้นบนวัดว่าคำขอถึงปลายทาง แล้วจริง เส้นล่างวัดว่าปลายทางที่ไม่ตั้งค่า CORS เขียนแถวให้คำขอข้ามต้นทาง
เส้นบนตัดข้อสันนิษฐานที่ว่าคำขอไม่ได้ถูกส่งออกไปทิ้ง เส้นล่างตัดข้อสันนิษฐานที่ว่าปลายทาง เป็นคนปฏิเสธทิ้ง สิ่งที่หน้าเว็บไม่ได้รับจึงเป็นคำตอบ ไม่ใช่สิทธิ์ในการส่งคำขอ
ที่วัดได้ กับที่สเปกกำหนด
หัวข้อที่มีชื่อว่า “ที่วัดได้ กับที่สเปกกำหนด”probe นับแถวได้ · spike นับคำขอได้ · ส่วนคำว่า ต้อง ปฏิเสธนั้นมาจาก Fetch Standard
ข้ออ้างสองข้อนี้แยกกันเด็ดขาด
- ที่วัดได้ คือ
200กับ+1แถวจาก probe ·รวมเป็น 0 จากตัวนับ header · และPOST= 1 กับTypeError: Failed to fetchจาก spike 20 รอบ - ที่สเปกกำหนด คือ Fetch Standard ระบุว่าเบราว์เซอร์ต้องเช็กคำตอบข้ามต้นทาง แล้วไม่ส่งให้ผู้เรียกเมื่อการเช็กนั้นไม่ผ่าน
สิ่งที่การวัดพิสูจน์ ไม่ใช่ว่าเบราว์เซอร์ ต้อง ทำแบบนี้ มันพิสูจน์ว่า chromium ที่ระบุรุ่นไว้ ทำแบบนี้ 20 รอบจาก 20 รอบ ส่วนคำว่าต้องนั้นเป็นข้อความในสเปก
ฝั่ง server วัดบน Spring Boot 4.1.1 · Kotlin 2.4.10 · JDK 25 ฝั่งเบราว์เซอร์วัดบน Playwright 1.61.0 กับ chromium 149.0.7827.55 และ 151.0.7922.34
ทรงที่ 1 ให้ผลชุดเดียวกันทุก byte บน chromium ทั้งสองใบ รวมข้อความใน console รุ่นอื่นเป็นคนละการวัด และบทนี้ไม่ได้อ้างถึงมัน
คำถามที่บทนี้ทิ้งไว้
หัวข้อที่มีชื่อว่า “คำถามที่บทนี้ทิ้งไว้”รู้แล้วว่า CORS ไม่ได้กันการเขียนในทรงนี้ ยังไม่รู้ว่ามีทรงไหนที่กันได้
ทรง cors-none ตอบคำถามของบทนี้จบแล้ว คือปลายทางที่ไม่ตั้งค่า CORS ไว้เลย
ยังเขียนแถวให้คำขอข้ามต้นทาง และเบราว์เซอร์ปฏิเสธแค่ขั้นอ่านคำตอบ
แต่ทรงที่ 1 ของ spike เป็นคำขอที่เบราว์เซอร์ส่งออกไปได้เลย ซึ่งเรียกว่า simple requestsimple requestคำขอที่เบราว์เซอร์ส่งออกไปได้เลย โดยไม่ถามปลายทางก่อน · คำนี้เป็นคำของ MDN ที่มาจากสเปก CORS ใบเก่า ส่วน Fetch Standard ไม่ได้ใช้คำนี้ · ทรงที่ 1 ของ spike วัดแล้วว่า server นับ POST ได้ 1 ครั้ง ขณะที่ฝั่ง JavaScript เห็น TypeError: Failed to fetch คำถามถัดไปจึงเป็นคำถามว่าคำขอที่ไม่เข้ารูปนั้นเดินทางอย่างไร
บทที่ 6 วัดทรงที่ปลายทางไม่เคยได้รับคำขอจริงเลย แล้วเทียบกับทรงที่ได้รับ โดยนับจาก method ที่ปลายทางเห็น ไม่ใช่จากข้อความที่ฝั่ง JavaScript เห็น
-
Fetch Standard — CORS protocol (เข้าถึง 2026-09-05) — ที่มาของ CORS protocol · หัวข้อนี้เขียนไว้เองว่ามันอธิบาย protocol ในมุมของผู้พัฒนาฝั่ง server ส่วนข้อกำหนดของ user agent อยู่ในขั้นตอน fetch · บทนี้อ้างมันเป็นสเปกเท่านั้น ไม่ได้วัดมัน
-
MDN — Cross-Origin Resource Sharing (CORS) (เข้าถึง 2026-09-05) — คำอธิบายของ header ตระกูล
Access-Control-Allow-* -
ตัวเลขฝั่ง server มาจาก
scripts/clinic-cross-origin/probe.pyข้อE1E2และE3· ต้องสั่งscripts/clinic-cross-origin/up.shก่อน ซึ่งยก container ของคอร์สนี้เอง ขึ้นที่ port 55437 -
ตัวเลขฝั่งเบราว์เซอร์มาจาก
scripts/clinic-cross-origin/browser/runner.mjs· ผลที่ตรึงไว้อยู่ที่results-20x-chromium149.jsonและresults-20x-chromium151.json
เช็กความเข้าใจ — บทที่ 5
ข้อ 1 / 3ทรง `cors-none` ยิง `POST /visits` ข้ามต้นทางด้วย session ที่ login แล้ว server ตอบอะไร