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

เบราว์เซอร์​บอก​ว่า​บล็อก แล้ว 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-none ไม่มี file CorsConfig.kt และ​ไม่มี​บรรทัด http.cors { } อยู่​ใน SecConfig.kt

probe.py:371 — นิยาม​ของ​ทรง​ที่​บท​นี้​วัด
"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 ล้วน ไม่มี​กลไก​อื่น​ปน​เข้า​มา

probe ตั้ง Origin ของ​ผู้​โจมตี​เอง​แล้ว​ยิง​ตรง ไม่มี​เบราว์เซอร์​อยู่​ใน​การ​วัด​ชุด​นี้​สัก​คำขอ​เดียว

probe.py:775 — คำขอ​ที่​ให้​ตัวเลข​พาด​หัว​ของ​บท​นี้
# 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 ตระกูล Access-Control-Allow-* ออก​มา​สัก​ตัว

probe.py:558 — ตัว​นับ​ที่​ให้​ตัวเลข 0 ของ​บท​นี้
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 และ​ฝั่ง​ปลาย​ทาง

browser/runner.mjs:9 — ทรง​ที่ 1 ของ spike
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 คือ

ข้อความ​จาก results-20x-chromium149.json · ทรง 1-simple-no-cors
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 ข้อ E1 E2 และ 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 ตอบอะไร