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

บรรทัด​เดียว​ที่​คอร์ส​ก่อน​หน้า​ปิด​ไว้ POST ที่​ไม่มี token ได้ 200 และ​แถว​ลง​หนึ่ง​แถว

คอร์ส​คัด​กรอง​ผู้​เรียก​ของ​คลินิก วาง​กฎ​ขั้น​ต่ำ​ไว้​ก้อน​หนึ่ง แล้ว​ตั้ง​ชื่อ​ก้อน​นั้น​เอง​ว่า SecConfig.kt — กฎขั้นต่ำที่ probe ใช้วัดทุกทรงหลังจากนี้ ก้อน​นี้​พิมพ์​ออก​มา​ใน​บท​เดียว แต่​ชื่อ​ของ​มัน​พูด​ถึง ทรง ไม่ใช่​บท คอร์ส​นั้น​วัด 12 ทรง และ 10 ทรง ใน​นั้น​ตั้ง​อยู่​บน​กฎ​ก้อน​นี้ อีก 2 ทรง​ไม่มี SecConfig.kt เลย เพราะ​เป็น สอง​ทรง​ที่​คอร์ส​นั้น​วัด​ไว้​ก่อน​จะ​เขียน​กฎ​ก้อน​นี้​ขึ้น​มา

ใน​ก้อน​นั้น​มี​บรรทัด http.csrf { it.disable() } อยู่​หนึ่ง​บรรทัด และ​ไม่มี​บท​ไหน​ของ​คอร์ส​นั้น บอก​ว่า​มัน​ปิด​อะไร​ไป บท​นี้​เอา​บรรทัด​นั้น​ออก แล้ว​วัด​ว่า​ช่อง​ไหน​เปลี่ยน

📦 clinic — ซี​รีส์​เดิม แต่ probe ของ​คอร์ส​นี้​สร้าง​โปร​เจ​กต์​ของ​ตัว​เอง

คอร์ส​นี้​ตอบ​บรรทัด​ที่ clinic-security ปิด​ไว้ แต่​ไม่​ได้​ยก​โปร​เจ​กต์ ใบ​เดิม​มา​ต่อ Visit ของ​ที่​นี่​มี​แค่ id กับ note ตาราง​ชื่อ csrf_visit และ container เป็น​คนละ​ใบ​กับ​ของ​คอร์ส​นั้น

ตัวเลข​ทุก​ตัว​ใน​บท​นี้​มา​จาก probe ที่​คุณ​รัน​เอง​ได้ สั่ง scripts/clinic-cross-origin/up.sh แล้ว​ตาม​ด้วย python3 scripts/clinic-cross-origin/probe.py

บรรทัด​ที่​แปดบท​วาง​อยู่​บน​มัน และ​ไม่มี​บท​ไหน​อธิบาย

หัวข้อ​ที่​มีชื่อ​ว่า “บรรทัด​ที่​แปดบท​วาง​อยู่​บน​มัน และ​ไม่มี​บท​ไหน​อธิบาย”

บรรทัด​นั้น​มี​ที่​อยู่​ที่​ระบุ​ได้ และ​คอร์ส​นั้น​ไม่​เคย​อธิบาย​มัน

บรรทัด​ที่​พูด​ถึง​อยู่​ที่ clinic-security/01-one-line-that-broke-the-contract.mdx บรรทัด​ที่ 83 ซึ่ง​เป็น​บรรทัด​แรก​ใน​ตัว​งาน​ของ fun chain

ก่อน​คอร์ส​นี้ ทั้ง​ไซต์​เอ่ย​ถึง CSRF กับ CORS รวม​กัน 2 บรรทัด นับ​เมื่อ 2026-09-05 โดย​ไม่​นับ​ไดเรกทอรี​ของ​คอร์ส​นี้​เอง บรรทัด​นั้น​คือ​หนึ่ง​ใน​สอง​บรรทัด อีก​บรรทัด​อยู่​ใน​หัวข้อ​สิ่ง​ที่​คอร์ส 35 จงใจ​ไม่​แตะ

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

it.disable() ปิด​กลไก​นั้น​ทั้ง​ตัว คำถาม​ของ​บท​นี้​จึง​เป็น​คำถาม​เดียว คือ​ปิด​แล้ว​ช่อง​ไหน​เปลี่ยน และ​ช่อง​นั้น​วัด​เป็น​อะไร​ได้

probe สร้าง​สอง​ทรง​จาก​รูป​เดียวกัน ทรง​หนึ่ง​มี​บรรทัด​นั้น อีก​ทรง​ไม่มี และ​ไม่มี byte อื่น​ต่าง​กัน

SecConfig.kt ของ​ทรง csrf-off
package clinic.csrf
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.core.userdetails.User
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.provisioning.InMemoryUserDetailsManager
import org.springframework.security.web.SecurityFilterChain
@Configuration
class SecConfig {
@Bean
fun chain(http: HttpSecurity): SecurityFilterChain {
http.csrf { it.disable() }
http.authorizeHttpRequests {
it.requestMatchers("/ping", "/csrf", "/login").permitAll()
it.anyRequest().authenticated()
}
http.formLogin { }
return http.build()
}
@Bean
fun users(): UserDetailsService = InMemoryUserDetailsManager(
User.withUsername("kaen-a").password("{noop}pw-for-probe-only").roles("USER").build(),
)
}

chain คืน bean ชนิด SecurityFilterChainSecurityFilterChainbean ที่​ประกาศ​ว่า​คำขอ​เดิน​ผ่าน filter ตัว​ไหน​บ้าง ตาม​ลำดับ​ใด · สิ่ง​ที่​มัน​ประกาศ​ไม่​ได้​บอก​ครบ​ว่า​สาย​นั้น​มี filter ตัว​ใด​บ้าง เพราะ bean ตัว​อื่น​เติม filter เข้า​สาย​เดียวกัน​ได้​ด้วย ซึ่ง​ประกาศ​ว่า​คำขอ​เดิน​ผ่าน filter ตัว​ไหน​บ้าง ทรง csrf-on คือ​รูป​เดียวกัน​นี้ ลบ​บรรทัด http.csrf { it.disable() } ทิ้ง​ทั้ง​บรรทัด

probe ตรวจ​ข้อ​นี้​เป็น​ข้อ​อ้าง​ของ​ตัว​เอง มัน​เทียบ​สอง​รูป​ที​ละ​บรรทัด แล้ว​รายงาน​ว่า​บรรทัด​ที่​มี เฉพาะ​ใน csrf-off คือ​บรรทัด​นั้น​บรรทัด​เดียว และ​บรรทัด​ที่​มี​เฉพาะ​ใน csrf-on มี 0 บรรทัด

POST /visits เขียน​หนึ่ง​แถว ส่วน /csrf คืน​ค่าที่ Spring เตรียม​ไว้​ให้​คำขอ​นั้น

Domain.kt — ปลาย​ทาง​สอง​ตัว​ที่​บท​นี้​ยิง​ใส่ · เหมือน​กัน​ทุก byte ทั้ง​สอง​ทรง
@GetMapping("/csrf")
fun csrf(token: CsrfToken?): Map<String, Any> =
if (token == null) mapOf("present" to false)
else mapOf(
"present" to true,
"headerName" to token.headerName,
"parameterName" to token.parameterName,
"token" to token.token,
)
/** เขียนหนึ่งแถว · รูปปกติของการเขียน */
@PostMapping("/visits")
fun create(): Map<String, Any> = mapOf("id" to svc.add("post"), "who" to who())

argument ชนิด CsrfTokenCsrfTokenค่าที่​คำขอ​ซึ่ง​เปลี่ยน​ข้อมูล​ต้อง​แนบ​มา​ด้วย · Spring คาย​มัน​ออก​มา​ให้​อ่าน​ก่อน แล้ว​รับ​กลับ​ได้​สอง​ทาง คือ header X-CSRF-TOKEN และ parameter _csrf · รหัส​ที่​ได้​เมื่อ​ไม่​แนบ​ค่า​นี้ ขึ้น​กับ​ทรง​ที่​วัด ไม่ใช่​ค่า​เดียว​ตายตัว ตัว​นั้น​เป็น nullable โดย​ตั้งใจ ค่า null คือ​ค่าที่​วัด​ได้ ไม่ใช่​ข้อ​ผิด​พลาด และ​เป็น​วิธี​ที่​บท​นี้​ถาม​ว่า Spring ฉีด​ค่า​นั้น​เข้า​มา​หรือ​ไม่

หน่วย​ของ​หลัก​ฐาน​ทั้ง​บท​มี​สอง​หน่วย คือ​รหัส HTTP ที่​ผู้​เรียก​ได้​รับ​กลับ​มา และ​จำนวน​แถว​ที่​เพิ่ม​ขึ้น​ใน​ตาราง csrf_visit นับ​ก่อน​ยิง​หนึ่ง​ครั้ง นับ​หลัง​ยิง​อีก​หนึ่ง​ครั้ง

ทรง csrf-off รับคำ​ขอ​ทั้ง​ที่​ไม่มี CSRF token มา​ด้วย และ​ไม่มี​ค่า​นั้น​ให้​ส่ง​ตั้งแต่​ต้น

ทุก​คำขอ​ใน​ตาราง​นี้​พก cookie ของ session ที่ login สำเร็จ​แล้ว ต่าง​กัน​แค่ header ที่​ประกาศ​เพิ่ม​เข้า​มา

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

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

ก่อน​หน้า​นั้น probe ยิง GET /csrf ด้วย session ใบ​เดียวกัน คำ​ตอบ​ที่​ได้​คือ present เป็น false แปล​ว่า Spring ไม่​ได้​ฉีด CsrfToken เข้า​มา​ให้​คำขอ​นี้​เลย

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

ทรง csrf-on ปฏิเสธ​คำขอ​ที่​ไม่มี CSRF token และ​รับคำ​ขอ​ที่​มี​ค่า​นั้น​ทั้ง​สอง​ช่อง​ทาง

คำขอ​ที่​ยิง​ใส่​ทรง csrf-onรหัสแถว​ใน csrf_visit
POST /visits ไม่มี CSRF token403+0
POST /visits · ส่ง​ค่า​นั้น​ทาง header X-CSRF-TOKEN200+1
POST /visits · ส่ง​ค่า​นั้น​ทาง parameter ของ​ฟอร์ม _csrf200+1

body ของ 403 ใบ​นั้น​มี​ช่อง error เป็น Forbidden และ​ช่อง path เป็น /visits ชื่อ header กับ​ชื่อ parameter ทั้ง​สอง​ตัว​ไม่​ได้​ถูก​เดา probe อ่าน​มา​จาก​คำ​ตอบ​ของ /csrf

สอง​แถว​ล่าง​จึง​บอก​อย่าง​เดียวกัน​สอง​ครั้ง คือ​กลไก​นี้​ตรวจ​ว่า ค่า​นั้น​มา​ถึง​หรือ​ไม่ ไม่​ได้​ตรวจ​ว่า​มัน​เดิน​ทาง​มา​ทาง​ช่อง​ไหน

เทียบ​สอง​ทรง​ช่อง​ต่อ​ช่อง ความ​ต่าง​ทั้งหมด​มา​จาก​บรรทัด​เดียว

ช่อง​ที่​วัดทรง csrf-offทรง csrf-on
/csrf ฉีด CsrfToken ให้​คำขอ​ไหมไม่​ฉีดฉีด
POST /visits ไม่มี CSRF token200 · +1 แถว403 · +0 แถว
POST /visits ส่ง​ค่า​นั้น​ทาง headerไม่มี​ค่า​ให้​ส่ง200 · +1 แถว
POST /visits ส่ง​ค่า​นั้น​ทาง parameter ของ​ฟอร์มไม่มี​ค่า​ให้​ส่ง200 · +1 แถว

authorizeHttpRequests เหมือน​กัน​ทั้ง​สอง​ทรง formLogin เหมือน​กัน ผู้​ใช้​คน​เดียวกัน และ​ปลาย​ทาง​เดียวกัน​ทุก byte ช่อง​ที่​ขยับ​จึง​ขยับ​เพราะ​บรรทัด​เดียว​นั้น

สิ่ง​ที่​บรรทัด​นั้น​ปิด ไม่ใช่​การ​ยืนยัน​ตัว ผู้​เรียก​ใน​ทั้ง​สอง​ทรง login สำเร็จ​เท่า​กัน สิ่ง​ที่​มัน​ปิด​คือ​คำถาม​ข้อ​ที่​สอง คือ​คำขอ​ใบ​นี้​มา​จาก​หน้า​เว็บ​ของ​แอป​เอง​หรือ​ไม่

เส้น​ทาง​ของ​คำขอ​ที่​ไม่มี CSRF token แยก​ออก​เป็น​สอง​ทาง​ที่​บรรทัด​เดียว​นั้น

flowchart TB
  Q["POST /visits · พก cookie ของ session ที่ login แล้ว · ไม่มี CSRF token"] --> L{"SecConfig มีบรรทัด http.csrf ที่ปิดทิ้งไหม"}
  L -->|"ทรง csrf-off · มีบรรทัดนั้น"| OFF["ไม่มีอะไรตรวจ CSRF token"]
  OFF --> OR["200 · csrf_visit เพิ่ม 1 แถว"]
  L -->|"ทรง csrf-on · ไม่มีบรรทัดนั้น"| ON["มีตัวตรวจว่าค่านั้นมาถึงหรือไม่"]
  ON -->|"คำขอใบนี้ ที่ไม่มีค่านั้น"| NR["403 · csrf_visit เพิ่ม 0 แถว"]
  ON -->|"คำขออีกใบ ที่แนบค่านั้นมาทาง header หรือทาง parameter"| OK["200 · csrf_visit เพิ่ม 1 แถว"]

คำ​บรรยาย​ภาพ: คำขอ​ใบ​เดียวกัน​เดิน​เข้า​มา​ที่​ทั้ง​สอง​ทรง ทรง​ที่​ปิด​ไว้​ไม่มี​อะไร​ตรวจ จึง​เขียน​หนึ่ง​แถว ส่วน​ทรง​ที่​เปิด​ไว้​ปฏิเสธ​จนกว่า​ค่า​นั้น​จะ​มา​ถึง

ตัวเลข​ทั้ง​บท​มา​จาก probe ฝั่ง server ไม่มี​เบราว์เซอร์​อยู่​ใน​การ​วัด​สัก​คำขอ​เดียว

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

ตัว​ที่​กำหนด​ว่า​เบราว์เซอร์ ตัดสิน​ใจ ใส่ Origin ให้​คำขอ​ใบ​ไหน​คือ Fetch Standard ซึ่ง​เป็น​สเปก ไม่ใช่​สิ่ง​ที่​บท​นี้​วัด แถว​ที่​สอง​ของ​ตาราง​ทรง csrf-off จึง​อ้าง​ได้​ข้อ​เดียว คือ server ทำ​อะไร​กับ header ที่​ประกาศ​ต้นทาง​อื่น​มา

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

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

403 กับ +0 แถว เป็น​ผล​ของ​ทรง​ที่ probe สร้าง​ขึ้น ไม่ใช่​กฎ​ของ Spring ที่​อ่าน​ได้​จาก​ที่​อื่น บท​ที่ 3 จะ​แสดง​คำขอ​ที่​รูป​เหมือน​กัน​แต่​ได้​รหัส​คนละ​ตัว

รู้​แล้ว​ว่า​บรรทัด​นั้น​ปิด​อะไร ยัง​ไม่รู้​ว่า​ค่าที่​ต้อง​แนบ​มา​นั้น​เป็น​ของ​ใคร

ทรง csrf-on รับคำ​ขอ​ที่​แนบ​ค่า​นั้น​มา และ​ปฏิเสธ​คำขอ​ที่​ไม่​แนบ ทั้ง​สอง​ผล​วัด​ได้​แล้ว แต่​ยัง​ไม่มี​ข้อ​ไหน​บอก​ว่า server เทียบ​ค่า​นั้น​กับ​อะไร

ถ้า​มัน​เป็น​ค่าที่​ใคร​ก็​หยิบ​ไป​ใช้ได้ กลไก​นี้​ก็​ไม่​กัน​อะไร​เลย ถ้า​มัน​ผูก​กับ​ผู้​เรียก​ราย​หนึ่ง คำถาม​ถัด​ไป​คือ​ผูก​กับ​อะไร​ของ​ผู้​เรียก​ราย​นั้น และ​หยิบ​ค่า​นั้น​มาส​องค​รั้ง​ได้​ค่า​เดิม​หรือ​ไม่

บท​ที่ 2 ยิง​คำถาม​นี้​เป็น​คำขอ มัน​หยิบ​ค่า​นั้น​จาก session หนึ่ง แล้ว​ส่ง​ไป​กับ​คำขอ​ของ อีก session หนึ่ง แล้ว​นับ​แถว​ดู


🔗 อ้างอิง​ต้นทาง​ของ​บท​นี้
  • Spring Security — Cross Site Request Forgery (CSRF) (เข้า​ถึง 2026-09-05) — ที่มา​ของ​ความ​หมาย​ของ http.csrf และ​ของ​ค่าที่​ผู้​เรียก​ต้อง​แนบ​มา

  • Fetch Standard — Origin header (เข้า​ถึง 2026-09-05) — สเปก​ที่​กำหนด​ว่า​เบราว์เซอร์​ใส่ Origin ให้​คำขอ​ใบ​ไหน · บท​นี้​อ้าง​มัน​เป็น​สเปก​เท่านั้น ไม่​ได้​วัด​มัน

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

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

ข้อ 1 / 3

ทรง `csrf-off` ยิง `POST /visits` โดยไม่มี CSRF token มาด้วย ได้อะไรกลับมา