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

method ที่​สเปก​เรียก​ว่า​ปลอดภัย เขียน​หนึ่ง​แถว​ให้​คำขอ​จาก​ต้นทาง​ของ​ผู้​โจมตี

บท​ที่ 1 เอา​บรรทัด http.csrf { it.disable() } ออก​จาก SecConfig.kt แล้ว​วัด​ว่า​ทรง csrf-on ปฏิเสธ POST /visits ที่​ไม่​แนบ CSRF token มา​ด้วย

บท​นี้​ไม่​แตะ SecConfig.kt สัก​ตัว​อักษร มัน​ยิง​ปลาย​ทาง​อีก​ตัว​หนึ่ง​ของ​แอป​เดียวกัน ด้วย​ทรง​ที่​เปิด​การ​กัน​ไว้​แล้ว แล้ว​นับ​แถว​ใน​ตาราง csrf_visit

GET /visits/touch เรียก svc.add ตัว​เดียว​กับ​ที่ POST /visits เรียก

Domain.kt — สอง​ปลาย​ทาง​ที่​เรียก​ตัว​เขียน​ตัว​เดียวกัน
/** เขียนหนึ่งแถว · รูปปกติของการเขียน */
@PostMapping("/visits")
fun create(): Map<String, Any> = mapOf("id" to svc.add("post"), "who" to who())
/** **ก็เขียนหนึ่งแถวเหมือนกัน** · จงใจ · เขียนอยู่หลัง method ที่ปลอดภัย */
@GetMapping("/visits/touch")
fun touch(): Map<String, Any> = mapOf("id" to svc.add("touch"), "who" to who())

svc.add ที่​ทั้ง​สอง​ปลาย​ทาง​เรียก เป็น​บรรทัด​เดียว และ​บรรทัด​นั้น​คือ​การ​บันทึก​ลง​ตาราง

Domain.kt — ตัว​เขียน​ที่​ทั้ง​สอง​ปลาย​ทาง​ใช้​ร่วม​กัน
@Service
class VisitService(private val repo: VisitRepo) {
fun add(note: String): Long = repo.save(Visit(note = note)).id ?: -1L
fun total(): Long = repo.count()
}

Domain.kt ถูก​ประกอบ​ขึ้น​จาก​ข้อความ​ชุด​เดียวกัน​ใน​ทุก​ทรง​ที่ probe สร้าง ทรง​หนึ่ง​กับ​อีก​ทรง​จึง​ไม่​ได้​ต่าง​กัน​ที่​ปลาย​ทาง ปลาย​ทาง​คู่​นี้​เหมือน​กัน​ทุก​ทรง

รูป​นี้​ไม่ใช่​รูป​ที่​แต่ง​ขึ้น​ให้​บท​เรียน​ดู​น่า​กลัว ปลาย​ทาง​ที่​นับ​ยอด​เข้า​ชม ต่อ​อายุ​ตะกร้า หรือ​ประทับ​เวลา​ที่​ผู้​ใช้​เปิด​หน้า ล้วน​เขียน​ฐาน​ข้อมูล​อยู่​หลัง GET กัน​ทั้ง​นั้น

RFC 9110 นิยาม​คำ​นี้​ที่​เจตนา​ของ​ผู้​เรียก ไม่​ได้​นิยาม​ที่​พฤติกรรม​ของ server

RFC 9110 หัวข้อ 9.2.1 จัด GET HEAD OPTIONS และ TRACE เป็น safe methodsafe methodmethod ที่​สเปก​จัด​ว่า​ไม่​เปลี่ยน​สถานะ เช่น GET · กลไก​กัน​ปลอม​จึง​ปล่อย​ผ่าน วัด​แล้ว​ว่า​ทรง csrf-on ให้ GET ที่​เขียน​ฐาน​ข้อมูล​จริง​ได้ 200 และ​แถว​เพิ่ม​หนึ่ง​แถว ทั้ง​ที่​ยิง​มา​จาก​ต้นทาง​ของ​ผู้​โจมตี นิยาม​ของ​มัน​คือ​ความ​หมาย​ที่ method นั้น​ประกาศ​ไว้​เป็นการ​อ่าน​อย่าง​เดียว​เป็น​หลัก ผู้​เรียก​ไม่​ได้​ขอ และ​ไม่​ได้​คาด​หวัง ว่า​สถานะ​บน server ต้นทาง​จะ​เปลี่ยน

ย่อหน้า​ถัด​มา​ใน​สเปก​เดียวกัน พูด​สิ่ง​ที่​ชื่อ​ของ​มัน​ชวน​ให้​เข้าใจ​ผิด นิยาม​นี้​ไม่​ได้​ห้าม ผู้​พัฒนา​ใส่​การ​ทำงาน​ที่​ไม่ใช่​การ​อ่าน​อย่าง​เดียว หรือ​ที่​มี​ผล​ข้าง​เคียง ลง​ไป​ใต้ safe method

สิ่ง​ที่​สเปก​บอก​ว่า​สำคัญ คือ​ผู้​เรียก​ไม่​ได้​ร้องขอ​การ​ทำงาน​ส่วน​เกิน​นั้น และ​รับ​ผิด​กับ​มัน​ไม่​ได้ ตัวอย่าง​สอง​อัน​ที่​สเปก​ยก​มา​เอง​คือ​การ​ต่อ​ท้าย log ทุก​คำขอ และ​การ​ตัด​เงิน​บัญชี​ค่า​โฆษณา เมื่อ​มี​คน​เลือก​โฆษณา​บน​หน้า​เว็บ

หัวใจ​ของ​บท​นี้

คำ​ว่า​ปลอดภัย​ใน​สเปก เป็น​คำ​สัญญา​เรื่อง เจตนา​ของ​ผู้​เรียก ไม่ใช่​หลัก​ประกัน เรื่อง​พฤติกรรม​ของ server

และ​สเปก​วาง​หน้าที่​ตาม​คำ​สัญญา​นั้น​ไว้​ที่​คน​เดียว คือ เจ้าของ resource ถ้า​ปลาย​ทาง​หนึ่ง​มี​ไว้​ทำ​สิ่ง​ที่​ไม่​ปลอดภัย เจ้าของ​ต้อง​ปิด​หรือ​ไม่​อนุญาต​ให้​ทำ​สิ่ง​นั้น ผ่าน safe method โดย​สเปก​ใช้​คำ​ว่า MUST กับ​หน้าที่​ข้อ​นี้

Spring Security ระบุ​ว่า​กลไก​กัน​ปลอม​ของ​มัน​ใช้ได้ ต่อ​เมื่อ​แอป​ทำให้ safe method อ่าน​อย่าง​เดียว

หน้า CSRF ของ Spring Security มี​หัวข้อ​ชื่อ Safe Methods Must be Read-only ใต้​หัวข้อ​นั้น​เขียน​ว่า กลไก​กัน​ปลอม​ทั้ง​สอง​แบบ​ที่​หน้า​นั้น​เสนอ จะ​ทำงาน​ได้​ก็​ต่อ​เมื่อ​แอป ทำให้ method ที่​สเปก​เรียก​ว่า​ปลอดภัย อ่าน​อย่าง​เดียว​จริง

หน้า​นั้น​ระบุ​ชื่อ​ไว้​สี่​ตัว​เท่ากับ​สเปก คือ GET HEAD OPTIONS และ TRACE และ​เขียน​ว่า​คำขอ​ที่​ใช้ method เหล่า​นี้ ไม่​ควร​เปลี่ยน​สถานะ​ของ​แอป

นี่​คือ​เงื่อนไข​ที่ framework ผลัก​กลับ​ไป​ให้​แอป ไม่ใช่​สิ่ง​ที่ framework บังคับ​ให้​เอง แอป​ที่ probe สร้าง​ขึ้น​ผิด​เงื่อนไข​ข้อ​นี้​โดย​ตั้งใจ และ​บท​นี้​วัด​ว่า​ผิด​แล้ว​ได้​อะไร​ออก​มา

ทรง csrf-on รับ GET /visits/touch ที่​ไม่มี CSRF token มา​ด้วย​เลย และ​แถว​ก็​ลง

คำขอ​ที่​ยิง​ใส่​ทรง csrf-onรหัสแถว​ใน csrf_visit
GET /visits/touch ไม่มี CSRF token200+1
GET /visits/touch ไม่มี CSRF token · ประกาศ Origin และ Referer ของ​ผู้​โจมตี200+1

ทั้ง​สอง​คำขอ​พก cookie ของ session ที่ login สำเร็จ​แล้ว และ​ไม่มี​ใบ​ไหน​แนบ CSRF token มา​ด้วย​สัก​ช่อง​ทาง​เดียว ทรง​นี้​คือ​ทรง​เดียว​กับ​ที่​ปฏิเสธ​การ​เขียน​ด้วย POST ใน​บท​ที่ 1

แถว​ที่​สอง​ประกาศ Origin เป็น https://evil.example และ Referer เป็น https://evil.example/attack.html ผล​ที่​ได้​เท่ากับ​แถว​แรก​ทุก​ช่อง

body ของ​คำ​ตอบ​ใบ​ที่​สอง​มี​ช่อง who เป็น kaen-a ซึ่ง​เป็น​ชื่อ​ผู้​ใช้​ที่ login ไว้ แถว​นั้น​จึง​ถูก​เขียน​ใน​นาม​ของ​เจ้าของ session ไม่ใช่​ใน​นาม​ของ​ผู้​ที่​ประกาศ​ต้นทาง​เข้า​มา

คำขอ​รูป​เดียวกัน​ที่​ไม่​พก cookie ของ session มา​ด้วย ไม่​ได้​แถว

probe ยิง GET /visits/touch อีก​ใบ​จาก session เปล่า ไม่มี cookie ติด​ไป​เลย ผล​ที่​วัด​ได้​คือ​แถว​ใน csrf_visit ไม่​เพิ่ม และ server ตอบ​กลับ​เป็น redirect แทนที่​จะ​เขียน

ความ​ต่าง​ระหว่าง​สอง​ใบ​นี้​ไม่​ได้​อยู่​ใน​ตัว​คำขอ ไม่​ได้​อยู่​ที่ header ที่​ประกาศ​ต้นทาง และ​ไม่​ได้​อยู่​ที่ CSRF token มัน​อยู่​ที่ cookie ของ session ใบ​เดียว

และ cookie คือ​สิ่ง​ที่​หน้า​เว็บ​ของ​ผู้​อื่น​ไม่​ต้อง​อ่าน​เลย CSRFCross-Site Request Forgery (CSRF)การ​ที่​หน้า​เว็บ​ของ​ผู้​อื่น​สั่ง​เบราว์เซอร์​ของ​ผู้​ใช้​ให้​ยิง​คำขอ​ไป​ยัง​แอป​ที่​ผู้​ใช้​ยัง login ค้าง​อยู่ · กลไก​กัน​ของ Spring Security คือ​ค่าที่​ผู้​เรียก​ต้อง​แนบ​มา​เอง ซึ่ง​หน้า​เว็บ​ของ​ผู้​อื่น​อ่าน​ไม่​ได้ ทั้ง​เรื่อง​ยืน​อยู่​บน​ช่อง​ว่าง ระหว่าง “คำขอ​นี้​พก cookie ของ​ใคร​มา” กับ “ใคร​เป็น​คน​สั่ง​ให้​คำขอ​นี้​ออก​เดิน​ทาง”

ทรง​ที่​ยืนยัน​ตัว​คนละ​แบบ ก็​เปิด​ช่อง​เดียวกัน

หัวข้อ​ที่​มีชื่อ​ว่า “ทรง​ที่​ยืนยัน​ตัว​คนละ​แบบ ก็​เปิด​ช่อง​เดียวกัน”

csrf-on-basic ยืนยัน​ตัว​ด้วย httpBasic แทน​ฟอร์ม login และ GET ที่​เขียน​ก็​ยัง​ผ่าน

probe ยิง GET /visits/touch ใส่​ทรง​นั้น โดย​พก header Authorization ไป​เอง ไม่มี CSRF token ใน​คำขอ ผล​คือ 200 และ​แถว​ใน csrf_visit เพิ่ม​หนึ่ง​แถว

ตัวเลข​คู่​นี้​เท่ากับ​ของ​ทรง csrf-on ทุก​ช่อง ทั้ง​ที่​สอง​ทรง​เลือก​วิธี​ยืนยัน​ตัว​คนละ​แบบ บท​ที่ 3 วัด​รหัส​ที่​ทรง​นี้​ตอบ​ให้​คำขอ​รูป​อื่น และ​เป็น​คนละ​คำถาม​กับ​บท​นี้

ช่อง​นี้​จึง​ไม่ใช่​คุณสมบัติ​ของ​การ​ยืนยัน​ตัว​ด้วย session มัน​มา​จาก​ข้อ​เดียว คือ​กลไก​กัน​ปลอม ไม่​ตรวจ method ที่​สเปก​เรียก​ว่า​ปลอดภัย ส่วน​ปลาย​ทาง​ที่​อยู่​หลัง method นั้น​เขียน​จริง

คำขอ​สอง​ใบ​ที่​ต่าง​กัน​แค่ cookie เดิน​คนละ​ทาง และ​ไม่มี​ทาง​ไหน​ถูก CSRF token หยุด​ไว้

flowchart TB
  Q["GET /visits/touch · ไม่มี CSRF token"] --> C{"คำขอใบนี้พก cookie ของ session ที่ login แล้วมาไหม"}
  C -->|"พกมา"| P["ตัวตรวจ CSRF token ปล่อยผ่าน เพราะ GET คือ method ที่สเปกเรียกว่าปลอดภัย"]
  P --> W["touch เรียก svc.add เหมือนที่ POST เรียก"]
  W --> R["200 · csrf_visit เพิ่ม 1 แถว · ใบที่ประกาศ Origin ของผู้โจมตี ได้เท่ากันทุกช่อง และ body คืน who เป็น kaen-a"]
  C -->|"ไม่ได้พกมา"| L["server ตอบกลับเป็น redirect"]
  L --> N["csrf_visit ไม่เพิ่มสักแถว"]

คำ​บรรยาย​ภาพ: เส้น​ทาง​ของ​คำขอ​แยก​ที่ cookie ไม่​ได้​แยก​ที่ CSRF token และ​ไม่​ได้​แยก​ที่ header ที่​ประกาศ​ต้นทาง ทาง​ที่​พก cookie มา​จบ​ที่​การ​เขียน​หนึ่ง​แถว

ตัวเลข​ทั้ง​บท​มา​จาก probe ฝั่ง server ส่วน​ความ​หมาย​ของ​คำ​ว่า​ปลอดภัย​มา​จาก​สเปก

probe ยิง​ทุก​คำขอ​ด้วย http.client ของ Python และ​ตั้ง Origin Referer และ Cookie เอง​ทุก​ตัว จึง​ไม่มี​เบราว์เซอร์​อยู่​ใน​การ​วัด​สัก​ใบ 200 กับ +1 แถว เป็น​ข้อ​อ้าง​เรื่อง สิ่ง​ที่ server ทำ​กับ​คำขอ​ที่มา​ถึง​แล้ว

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

นิยาม​ของ safe method กับ​หน้าที่​ของ​เจ้าของ resource เป็น​ข้อ​อ้าง​เรื่อง​สเปก อ่าน​ได้​จาก RFC 9110 ตรง​ตัว ส่วน​ข้อ​ที่​ว่า​แอป​ตัว​นี้​ทำ​อะไร​เมื่อ​ผิด​เงื่อนไข​นั้น อ่าน​ได้​จาก probe เท่านั้น

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

ผล​ทั้งหมด​ใน​บท​นี้​วัด​บน Spring Boot 4.1.1 · Kotlin 2.4.10 · JDK 25 รุ่น​อื่น​เป็น​คนละ​การ​วัด และ​บท​นี้​ไม่​ได้​อ้าง​ถึง​มัน

200 กับ +1 แถว เป็น​ผล​ของ​ทรง​ที่ probe สร้าง​ขึ้น ไม่ใช่​กฎ​ของ Spring ที่​ยก​ไป​ใช้ กับ​แอป​อื่น​ได้​ทันที สิ่ง​ที่​ยก​ไป​ได้​คือ​คำถาม ว่า​ปลาย​ทาง​ไหน​ใน​แอป​ของ​คุณ​เขียน​อยู่​หลัง GET

รู้​แล้ว​ว่า method ที่​สเปก​เรียก​ว่า​ปลอดภัย เขียน​แถว​ได้ ยัง​ไม่รู้​ว่า​อะไร​กัน​มัน​ได้

ตลอด​บท​นี้ server ตอบ 200 ให้​คำขอ​ที่​ประกาศ​ต้นทาง​ของ​ผู้​โจมตี​มา​ตรงๆ ไม่มี​คำขอ​ใบ​ไหน​ถูก​ปฏิเสธ​เพราะ​ต้นทาง​ที่​มัน​ประกาศ

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

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


🔗 อ้างอิง​ต้นทาง​ของ​บท​นี้
  • RFC 9110 — 9.2.1 Safe Methods (เข้า​ถึง 2026-09-05) — นิยาม​ของ​คำ​ว่า safe · ข้อ​ที่​ว่า​นิยาม​นี้​ไม่​ได้​ห้าม​ผล​ข้าง​เคียง · และ​หน้าที่​ที่​สเปก​วาง​ไว้​ที่​เจ้าของ resource

  • Spring Security — Cross Site Request Forgery (CSRF) (เข้า​ถึง 2026-09-05) — หัวข้อ Safe Methods Must be Read-only · บท​นี้​อ้าง​มัน​เป็น​เอกสาร ไม่ใช่​สิ่ง​ที่​วัด

  • ตัวเลข​ทุก​ตัว​ใน​บท​นี้​มา​จาก scripts/clinic-cross-origin/probe.py ข้อ C1 C2 และ D5 · ส่วน​ใบ​ที่​ไม่​พก cookie อ่าน​จาก​ช่อง touchNoSession ของ​ทรง csrf-on ใน​ผล​ที่ probe บันทึก​ไว้ ไม่ใช่​จาก​ข้อ​อ้าง​สาม​ข้อ​นั้น · ต้อง​สั่ง scripts/clinic-cross-origin/up.sh ก่อน ซึ่ง​ยก container ของ​คอร์ส​นี้​เอง ขึ้น​ที่ port 55437

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

ข้อ 1 / 3

ทรง `csrf-on` ยิง `GET /visits/touch` ด้วย session ที่ login แล้ว โดยไม่แนบ CSRF token เลย ได้อะไรกลับมา