method ที่สเปกเรียกว่าปลอดภัย เขียนหนึ่งแถวให้คำขอจากต้นทางของผู้โจมตี
บทที่ 1 เอาบรรทัด
http.csrf { it.disable() } ออกจาก SecConfig.kt แล้ววัดว่าทรง csrf-on ปฏิเสธ
POST /visits ที่ไม่แนบ CSRF token มาด้วย
บทนี้ไม่แตะ SecConfig.kt สักตัวอักษร มันยิงปลายทางอีกตัวหนึ่งของแอปเดียวกัน
ด้วยทรงที่เปิดการกันไว้แล้ว แล้วนับแถวในตาราง csrf_visit
ปลายทางที่เขียนหนึ่งแถว โดยไม่ใช่ POST
หัวข้อที่มีชื่อว่า “ปลายทางที่เขียนหนึ่งแถว โดยไม่ใช่ POST”
GET /visits/touchเรียกsvc.addตัวเดียวกับที่POST /visitsเรียก
/** เขียนหนึ่งแถว · รูปปกติของการเขียน */ @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 ที่ทั้งสองปลายทางเรียก เป็นบรรทัดเดียว และบรรทัดนั้นคือการบันทึกลงตาราง
@Serviceclass 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 เขียนเงื่อนไขข้อเดียวกันไว้
หัวข้อที่มีชื่อว่า “เอกสารของ Spring เขียนเงื่อนไขข้อเดียวกันไว้”Spring Security ระบุว่ากลไกกันปลอมของมันใช้ได้ ต่อเมื่อแอปทำให้ safe method อ่านอย่างเดียว
หน้า CSRF ของ Spring Security มีหัวข้อชื่อ Safe Methods Must be Read-only
ใต้หัวข้อนั้นเขียนว่า กลไกกันปลอมทั้งสองแบบที่หน้านั้นเสนอ จะทำงานได้ก็ต่อเมื่อแอป
ทำให้ method ที่สเปกเรียกว่าปลอดภัย อ่านอย่างเดียวจริง
หน้านั้นระบุชื่อไว้สี่ตัวเท่ากับสเปก คือ GET HEAD OPTIONS และ TRACE
และเขียนว่าคำขอที่ใช้ method เหล่านี้ ไม่ควรเปลี่ยนสถานะของแอป
นี่คือเงื่อนไขที่ framework ผลักกลับไปให้แอป ไม่ใช่สิ่งที่ framework บังคับให้เอง แอปที่ probe สร้างขึ้นผิดเงื่อนไขข้อนี้โดยตั้งใจ และบทนี้วัดว่าผิดแล้วได้อะไรออกมา
ยิงจริง ทั้งที่การกัน CSRF เปิดอยู่
หัวข้อที่มีชื่อว่า “ยิงจริง ทั้งที่การกัน CSRF เปิดอยู่”ทรง
csrf-onรับGET /visits/touchที่ไม่มี CSRF token มาด้วยเลย และแถวก็ลง
คำขอที่ยิงใส่ทรง csrf-on | รหัส | แถวใน csrf_visit |
|---|---|---|
GET /visits/touch ไม่มี CSRF token | 200 | +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
หัวข้อที่มีชื่อว่า “สิ่งเดียวที่คำขอนั้นต้องมีคือ cookie”คำขอรูปเดียวกันที่ไม่พก 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ข้อC1C2และ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 เลย ได้อะไรกลับมา